From 3a2f0f4de1c91fe46964472f51867cae7287969d Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:23:59 +0000 Subject: [PATCH 1/4] [Tests] Add unit tests for hrtime utilities --- .../cli-kit/src/public/node/hrtime.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/cli-kit/src/public/node/hrtime.test.ts diff --git a/packages/cli-kit/src/public/node/hrtime.test.ts b/packages/cli-kit/src/public/node/hrtime.test.ts new file mode 100644 index 00000000000..d93143e8630 --- /dev/null +++ b/packages/cli-kit/src/public/node/hrtime.test.ts @@ -0,0 +1,51 @@ +import {startHRTime, endHRTimeInMs} from './hrtime.js' +import {describe, test, expect, vi, afterEach} from 'vitest' + +describe('hrtime', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + test('startHRTime returns the current high-resolution real time', () => { + // Given + const mockTime: [number, number] = [123, 456] + vi.spyOn(process, 'hrtime').mockReturnValue(mockTime) + + // When + const result = startHRTime() + + // Then + expect(process.hrtime).toHaveBeenCalled() + expect(result).toEqual(mockTime) + }) + + test('endHRTimeInMs returns the time in milliseconds with 2 decimal places', () => { + // Given + const startTime: [number, number] = [100, 0] + const diffTime: [number, number] = [1, 500000] + vi.spyOn(process, 'hrtime').mockReturnValue(diffTime) + + // When + const result = endHRTimeInMs(startTime) + + // Then + expect(process.hrtime).toHaveBeenCalledWith(startTime) + // 1 second + 500,000 nanoseconds = 1000ms + 0.5ms = 1000.5ms + expect(result).toBe('1000.50') + }) + + test('endHRTimeInMs handles sub-millisecond precision', () => { + // Given + const startTime: [number, number] = [100, 0] + const diffTime: [number, number] = [0, 1234567] + vi.spyOn(process, 'hrtime').mockReturnValue(diffTime) + + // When + const result = endHRTimeInMs(startTime) + + // Then + expect(process.hrtime).toHaveBeenCalledWith(startTime) + // 0 seconds + 1,234,567 nanoseconds = 1.234567ms + expect(result).toBe('1.23') + }) +}) From 6d9ff79684272c465918c610e336eabf8ab67c66 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:44:15 +0000 Subject: [PATCH 2/4] [Tests] Add unit tests for hrtime utilities --- packages/cli-kit/src/public/node/hrtime.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli-kit/src/public/node/hrtime.test.ts b/packages/cli-kit/src/public/node/hrtime.test.ts index d93143e8630..aebb6b4501b 100644 --- a/packages/cli-kit/src/public/node/hrtime.test.ts +++ b/packages/cli-kit/src/public/node/hrtime.test.ts @@ -1,11 +1,7 @@ import {startHRTime, endHRTimeInMs} from './hrtime.js' -import {describe, test, expect, vi, afterEach} from 'vitest' +import {describe, test, expect, vi} from 'vitest' describe('hrtime', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - test('startHRTime returns the current high-resolution real time', () => { // Given const mockTime: [number, number] = [123, 456] From c4736c485969e20dd56eddd6b975235bb96f8f51 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:51:18 +0000 Subject: [PATCH 3/4] [Tests] Add unit tests for hrtime utilities --- .changeset/app-config-validate-client-id.md | 5 - .changeset/app-dev-non-interactive-flags.md | 5 - .changeset/config.json | 1 + .changeset/exact-domain-store-lookup.md | 5 - .../skip-external-type-generation-imports.md | 5 - .../theme-dev-reconciliation-strategy.md | 5 - .github/workflows/tests-pr.yml | 12 +- .../esbuild-plugin-graphiql-imports.js | 13 +- .../generated/generated_docs_data_v2.json | 209 +-- packages/app/CHANGELOG.md | 9 - packages/app/package.json | 10 +- .../convert_dev_to_transfer_disabled_store.ts | 30 + .../src/cli/commands/app/bulk/cancel.test.ts | 68 - .../src/cli/commands/app/bulk/execute.test.ts | 112 -- .../src/cli/commands/app/bulk/status.test.ts | 76 -- .../cli/commands/app/config/validate.test.ts | 46 - .../src/cli/commands/app/config/validate.ts | 8 +- packages/app/src/cli/commands/app/dev.test.ts | 72 -- packages/app/src/cli/commands/app/dev.ts | 15 +- .../app/src/cli/commands/app/init.test.ts | 441 +++---- .../app/src/cli/models/app/app.test-data.ts | 10 + packages/app/src/cli/models/app/app.test.ts | 7 +- packages/app/src/cli/models/app/app.ts | 8 +- .../app/src/cli/models/app/identifiers.ts | 4 - .../extensions/extension-instance.test.ts | 27 +- .../models/extensions/extension-instance.ts | 29 +- .../specifications/type-generation.test.ts | 332 +---- .../specifications/type-generation.ts | 144 +-- .../extensions/specifications/ui_extension.ts | 26 +- .../cli/models/project/active-config.test.ts | 48 - .../src/cli/models/project/active-config.ts | 24 +- packages/app/src/cli/prompts/dev.ts | 9 + .../services/app/config/link-service.test.ts | 3 - .../src/cli/services/app/config/link.test.ts | 54 - .../app/src/cli/services/app/config/link.ts | 17 - packages/app/src/cli/services/deploy.ts | 16 +- .../src/cli/services/deploy/bundle.test.ts | 108 +- .../app/src/cli/services/deploy/bundle.ts | 10 + packages/app/src/cli/services/dev.ts | 5 - .../dev/app-events/app-event-watcher.test.ts | 30 +- .../dev/app-events/file-watcher.test.ts | 1138 +++++++++-------- .../dev/processes/setup-dev-processes.ts | 1 - .../dev/processes/theme-app-extension.test.ts | 18 - .../dev/processes/theme-app-extension.ts | 3 +- .../src/cli/services/dev/select-store.test.ts | 38 +- .../app/src/cli/services/dev/select-store.ts | 103 +- .../cli/services/extensions/bundle.test.ts | 170 ++- .../src/cli/services/store-context.test.ts | 9 +- .../app/src/cli/services/store-context.ts | 5 +- .../app/src/cli/services/validate.test.ts | 4 +- packages/app/src/cli/services/validate.ts | 3 +- .../utilities/developer-platform-client.ts | 7 + .../app-management-client.test.ts | 96 -- .../app-management-client.ts | 33 +- .../partners-client.ts | 11 + packages/app/src/cli/utilities/mkcert.test.ts | 38 - packages/app/src/cli/utilities/mkcert.ts | 4 +- packages/cli-kit/CHANGELOG.md | 2 - packages/cli-kit/package.json | 2 +- .../src/private/node/content-tokens.test.ts | 33 +- .../src/private/node/content-tokens.ts | 40 +- .../src/private/node/session/scopes.ts | 11 +- .../cli-kit/src/public/common/array.test.ts | 128 +- .../cli-kit/src/public/common/object.test.ts | 35 - packages/cli-kit/src/public/common/object.ts | 31 - packages/cli-kit/src/public/common/version.ts | 2 +- .../cli-kit/src/public/node/analytics.test.ts | 77 +- packages/cli-kit/src/public/node/analytics.ts | 5 +- .../src/public/node/error-handler.test.ts | 14 - .../cli-kit/src/public/node/error-handler.ts | 19 +- .../src/public/node/graphiql/server.ts | 4 +- .../cli-kit/src/public/node/hrtime.test.ts | 8 +- .../src/public/node/node-package-manager.ts | 12 +- .../cli-kit/src/public/node/promises.test.ts | 54 - .../cli-kit/src/public/node/system.test.ts | 10 - packages/cli-kit/src/public/node/system.ts | 5 +- packages/cli/CHANGELOG.md | 9 - packages/cli/README.md | 181 +-- packages/cli/oclif.manifest.json | 42 +- packages/cli/package.json | 14 +- packages/create-app/CHANGELOG.md | 2 - packages/create-app/oclif.manifest.json | 2 +- packages/create-app/package.json | 6 +- packages/e2e/data/snapshots/commands.txt | 7 +- packages/e2e/scripts/cleanup-stores.ts | 139 +- packages/e2e/scripts/prime-browser-auth.ts | 104 +- packages/e2e/setup/app.ts | 20 +- packages/e2e/tests/dev-hot-reload.spec.ts | 5 +- packages/organizations/CHANGELOG.md | 6 - packages/organizations/package.json | 4 +- packages/plugin-cloudflare/CHANGELOG.md | 6 - packages/plugin-cloudflare/package.json | 4 +- .../src/install-cloudflared.test.ts | 11 +- packages/plugin-did-you-mean/CHANGELOG.md | 6 - packages/plugin-did-you-mean/package.json | 4 +- packages/plugin-did-you-mean/src/index.ts | 21 +- packages/store/CHANGELOG.md | 14 - packages/store/package.json | 6 +- .../src/cli/commands/store/auth/list.test.ts | 1 + .../store/src/cli/commands/store/auth/list.ts | 2 + .../cli/commands/store/create/preview.test.ts | 4 + .../src/cli/commands/store/create/preview.ts | 2 + packages/store/src/cli/commands/store/list.ts | 2 + packages/store/src/cli/commands/store/open.ts | 2 + packages/store/src/cli/flags.ts | 2 +- .../store/create/preview/client.test.ts | 24 +- .../cli/services/store/execute/result.test.ts | 38 +- packages/theme/CHANGELOG.md | 6 - packages/theme/package.json | 4 +- packages/theme/src/cli/commands/theme/dev.ts | 10 +- packages/theme/src/cli/services/dev.ts | 9 +- .../theme-environment/remote-theme-watcher.ts | 2 - .../theme-environment.test.ts | 2 +- .../theme-environment/theme-environment.ts | 1 - .../theme-reconciliation.test.ts | 114 +- .../theme-environment/theme-reconciliation.ts | 86 +- .../cli/utilities/theme-environment/types.ts | 10 - .../src/cli/utilities/theme-store.test.ts | 45 - pnpm-lock.yaml | 36 +- 119 files changed, 1556 insertions(+), 3690 deletions(-) delete mode 100644 .changeset/app-config-validate-client-id.md delete mode 100644 .changeset/app-dev-non-interactive-flags.md delete mode 100644 .changeset/exact-domain-store-lookup.md delete mode 100644 .changeset/skip-external-type-generation-imports.md delete mode 100644 .changeset/theme-dev-reconciliation-strategy.md create mode 100644 packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts delete mode 100644 packages/app/src/cli/commands/app/bulk/cancel.test.ts delete mode 100644 packages/app/src/cli/commands/app/bulk/execute.test.ts delete mode 100644 packages/app/src/cli/commands/app/bulk/status.test.ts delete mode 100644 packages/app/src/cli/commands/app/dev.test.ts delete mode 100644 packages/cli-kit/src/public/node/promises.test.ts delete mode 100644 packages/theme/src/cli/utilities/theme-store.test.ts diff --git a/.changeset/app-config-validate-client-id.md b/.changeset/app-config-validate-client-id.md deleted file mode 100644 index 5b993973db1..00000000000 --- a/.changeset/app-config-validate-client-id.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Allow `app config validate` to target configs by client ID and report the validated configuration. diff --git a/.changeset/app-dev-non-interactive-flags.md b/.changeset/app-dev-non-interactive-flags.md deleted file mode 100644 index f59065d305c..00000000000 --- a/.changeset/app-dev-non-interactive-flags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Add non-interactive `app dev` options: `--store-password` and `--install-mkcert`. diff --git a/.changeset/config.json b/.changeset/config.json index d12fe1e0daa..b843da70cb5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -11,6 +11,7 @@ "@shopify/create-app", "@shopify/cli-kit", "@shopify/theme", + "@shopify/ui-extensions-dev-console-app", "@shopify/plugin-cloudflare", "@shopify/plugin-did-you-mean" ]], diff --git a/.changeset/exact-domain-store-lookup.md b/.changeset/exact-domain-store-lookup.md deleted file mode 100644 index b8aaa62238b..00000000000 --- a/.changeset/exact-domain-store-lookup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Fix store lookup by domain to require an exact domain match. diff --git a/.changeset/skip-external-type-generation-imports.md b/.changeset/skip-external-type-generation-imports.md deleted file mode 100644 index b7360155bb5..00000000000 --- a/.changeset/skip-external-type-generation-imports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/cli': patch ---- - -Improve extension type generation by avoiding unnecessary scans outside extension source files. diff --git a/.changeset/theme-dev-reconciliation-strategy.md b/.changeset/theme-dev-reconciliation-strategy.md deleted file mode 100644 index 64878072a71..00000000000 --- a/.changeset/theme-dev-reconciliation-strategy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/theme': minor ---- - -Add a `--reconciliation-strategy` option to `theme dev`. diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 1403f719d42..d827e4dfd9c 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -289,30 +289,28 @@ jobs: - name: Install Playwright Chromium run: pnpm exec playwright install chromium working-directory: packages/e2e - - name: Build CLI for cleanup auth - run: pnpm nx run cli:build --skip-nx-cache - - name: Prime cleanup auth state + - name: Prime cleanup browser auth state env: E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} run: pnpm --filter e2e exec tsx scripts/prime-browser-auth.ts - - name: Cleanup current-run E2E apps + - name: Cleanup current-run E2E stores env: E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} run: | RUN_TOKEN=$(node -e "process.stdout.write(BigInt(process.env.GITHUB_RUN_ID).toString(36))") - pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}" - - name: Cleanup current-run E2E stores + pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}" + - name: Cleanup current-run E2E apps env: E2E_ACCOUNT_EMAIL: ${{ secrets.E2E_ACCOUNT_EMAIL }} E2E_ACCOUNT_PASSWORD: ${{ secrets.E2E_ACCOUNT_PASSWORD }} E2E_ORG_ID: ${{ secrets.E2E_ORG_ID }} run: | RUN_TOKEN=$(node -e "process.stdout.write(BigInt(process.env.GITHUB_RUN_ID).toString(36))") - pnpm --filter e2e exec tsx scripts/cleanup-stores.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}" + pnpm --filter e2e exec tsx scripts/cleanup-apps.ts --pattern "r${RUN_TOKEN}a${GITHUB_RUN_ATTEMPT}" type-diff: if: github.event.pull_request.head.repo.full_name == github.repository diff --git a/bin/bundling/esbuild-plugin-graphiql-imports.js b/bin/bundling/esbuild-plugin-graphiql-imports.js index c81beff873e..bd201644236 100644 --- a/bin/bundling/esbuild-plugin-graphiql-imports.js +++ b/bin/bundling/esbuild-plugin-graphiql-imports.js @@ -23,10 +23,11 @@ const resolveGraphiQLAssetHelper = `function resolveGraphiQLAsset(asset) { const GraphiQLImportsPlugin = { name: 'GraphiQLImportsPlugin', setup(build) { - // GraphiQL uses require.resolve with paths that won't work after esbuild moves this module. - // We need to replace them with valid paths. The bundled CLI copies cli-kit assets to dist/assets, - // so rewrite to a resolver that works whether esbuild emits the GraphiQL server into a top-level - // shared chunk or a nested command file. + // GraphiQL uses require.resolve with paths that won't work with esbuild + // We need to replace them with valid paths + // graphiql/server.ts uses require.resolve('@shopify/cli-kit/assets/...'). The bundled CLI does not ship + // @shopify/cli-kit as a dependency; assets are copied to dist/assets. Rewrite to a resolver that works whether + // esbuild emits the GraphiQL server into a top-level shared chunk or a nested command file. build.onLoad({filter: /[/\\]graphiql[/\\]server\.[cm]?[jt]s$/}, async (args) => { const contents = await readFile(args.path, 'utf8') if (!createRequireStatement.test(contents)) { @@ -40,9 +41,7 @@ const GraphiQLImportsPlugin = { contents: contents .replace(createRequireStatement, (match) => `${match}\n${resolveGraphiQLAssetHelper}`) .replace("require.resolve('@shopify/cli-kit/assets/graphiql/favicon.ico')", "resolveGraphiQLAsset('favicon.ico')") - .replace("require.resolve('@shopify/cli-kit/assets/graphiql/style.css')", "resolveGraphiQLAsset('style.css')") - .replace("require.resolve('../../../../assets/graphiql/favicon.ico')", "resolveGraphiQLAsset('favicon.ico')") - .replace("require.resolve('../../../../assets/graphiql/style.css')", "resolveGraphiQLAsset('style.css')"), + .replace("require.resolve('@shopify/cli-kit/assets/graphiql/style.css')", "resolveGraphiQLAsset('style.css')"), } }) }, diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index e8f2e68c87c..4b8eff9ffba 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -979,15 +979,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--install-mkcert", - "value": "''", - "description": "Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_INSTALL_MKCERT" - }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -1051,15 +1042,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--store-password ", - "value": "string", - "description": "The password for storefronts with password protection.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STORE_PASSWORD" - }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -1133,7 +1115,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME" } ], - "value": "export interface appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvpull": { @@ -4693,44 +4675,6 @@ "value": "export interface search {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "storeauthlist": { - "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts", - "name": "storeauthlist", - "description": "The following flags are available for the `store auth list` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-auth-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - } - ], - "value": "export interface storeauthlist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "storeauth": { "docs-shopify.dev/commands/interfaces/store-auth.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", @@ -4985,62 +4929,6 @@ "value": "export interface storebulkstatus {\n /**\n * The bulk operation ID (numeric ID or full GID). If not provided, lists all bulk operations on this store in the last 7 days.\n * @environment SHOPIFY_FLAG_ID\n */\n '--id '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "storecreatepreview": { - "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "name": "storecreatepreview", - "description": "The following flags are available for the `store create preview` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--country ", - "value": "string", - "description": "Two-letter country code for the store, such as US, CA, or GB.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STORE_COUNTRY" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--name ", - "value": "string", - "description": "The name of the store.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PREVIEW_STORE_NAME" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-create-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - } - ], - "value": "export interface storecreatepreview {\n /**\n * Two-letter country code for the store, such as US, CA, or GB.\n * @environment SHOPIFY_FLAG_STORE_COUNTRY\n */\n '--country '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The name of the store.\n * @environment SHOPIFY_FLAG_PREVIEW_STORE_NAME\n */\n '--name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "storeexecute": { "docs-shopify.dev/commands/interfaces/store-execute.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", @@ -5269,90 +5157,6 @@ "value": "export interface storeinfo {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "storelist": { - "docs-shopify.dev/commands/interfaces/store-list.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "name": "storelist", - "description": "The following flags are available for the `store list` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--organization-id ", - "value": "string", - "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - } - ], - "value": "export interface storelist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The numeric organization ID. Auto-selects if you belong to a single organization.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, - "storeopen": { - "docs-shopify.dev/commands/interfaces/store-open.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/store-open.interface.ts", - "name": "storeopen", - "description": "The following flags are available for the `store open` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/store-open.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-open.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-open.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-s, --store ", - "value": "string", - "description": "The myshopify.com domain of the store.", - "environmentValue": "SHOPIFY_FLAG_STORE" - } - ], - "value": "export interface storeopen {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "themecheck": { "docs-shopify.dev/commands/interfaces/theme-check.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts", @@ -5789,15 +5593,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PORT" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--reconciliation-strategy ", - "value": "string", - "description": "How to resolve JSON conflicts when --theme-editor-sync is enabled. Use keep-local to keep local files, keep-remote to keep remote files, or abort to fail instead of prompting.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RECONCILIATION_STRATEGY" - }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -5898,7 +5693,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * How to resolve JSON conflicts when --theme-editor-sync is enabled. Use keep-local to keep local files, keep-remote to keep remote files, or abort to fail instead of prompting.\n * @environment SHOPIFY_FLAG_RECONCILIATION_STRATEGY\n */\n '--reconciliation-strategy '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themedev {\n /**\n * Allow development on a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Controls the visibility of the error overlay when an theme asset upload fails:\n- silent Prevents the error overlay from appearing.\n- default Displays the error overlay.\n \n * @environment SHOPIFY_FLAG_ERROR_OVERLAY\n */\n '--error-overlay '?: string\n\n /**\n * Set which network interface the web server listens on. The default value is 127.0.0.1.\n * @environment SHOPIFY_FLAG_HOST\n */\n '--host '?: string\n\n /**\n * Skip hot reloading any files that match the specified pattern.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * The live reload mode switches the server behavior when a file is modified:\n- hot-reload Hot reloads local changes to CSS and sections (default)\n- full-page Always refreshes the entire page\n- off Deactivate live reload\n * @environment SHOPIFY_FLAG_LIVE_RELOAD\n */\n '--live-reload '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevents files from being deleted in the remote theme when a file has been deleted locally. This applies to files that are deleted while the command is running, and files that have been deleted locally before the command is run.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * Hot reload only files that match the specified pattern.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Local port to serve theme preview from. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port '?: string\n\n /**\n * Inject the standard events inspector into storefront HTML.\n * @environment SHOPIFY_FLAG_STANDARD_EVENTS_INSPECTOR\n */\n '--standard-events-inspector'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Synchronize Theme Editor updates in the local theme files.\n * @environment SHOPIFY_FLAG_THEME_EDITOR_SYNC\n */\n '--theme-editor-sync'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themeduplicate": { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index d1a5c0e2590..ede9a3c16d4 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,14 +1,5 @@ # @shopify/app -## 4.5.0 - -### Patch Changes - -- @shopify/organizations@4.5.0 -- @shopify/cli-kit@4.5.0 -- @shopify/theme@4.5.0 -- @shopify/plugin-cloudflare@4.5.0 - ## 4.4.0 ### Minor Changes diff --git a/packages/app/package.json b/packages/app/package.json index ab3d5573951..953cd152a0a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/app", - "version": "4.5.0", + "version": "4.4.0", "packageManager": "pnpm@10.11.1", "description": "Utilities for loading, building, and publishing apps.", "homepage": "https://github.com/shopify/cli#readme", @@ -61,10 +61,10 @@ "@graphql-typed-document-node/core": "3.2.0", "@luckycatfactory/esbuild-graphql-loader": "3.8.1", "@oclif/core": "4.8.3", - "@shopify/cli-kit": "4.5.0", - "@shopify/plugin-cloudflare": "4.5.0", - "@shopify/organizations": "4.5.0", - "@shopify/theme": "4.5.0", + "@shopify/cli-kit": "4.4.0", + "@shopify/plugin-cloudflare": "4.4.0", + "@shopify/organizations": "4.4.0", + "@shopify/theme": "4.4.0", "@shopify/theme-check-node": "3.26.1", "@shopify/toml-patch": "0.3.0", "chokidar": "3.6.0", diff --git a/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts b/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts new file mode 100644 index 00000000000..51976dccb1f --- /dev/null +++ b/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts @@ -0,0 +1,30 @@ +import {gql} from 'graphql-request' + +export const ConvertDevToTransferDisabledStoreQuery = gql` + mutation convertDevToTestStore($input: ConvertDevToTestStoreInput!) { + convertDevToTestStore(input: $input) { + convertedToTestStore + userErrors { + message + field + } + } + } +` + +export interface ConvertDevToTransferDisabledStoreVariables { + input: { + organizationID: number + shopId: string + } +} + +export interface ConvertDevToTransferDisabledSchema { + convertDevToTestStore: { + convertedToTestStore: boolean + userErrors: { + field: string[] + message: string + }[] + } +} diff --git a/packages/app/src/cli/commands/app/bulk/cancel.test.ts b/packages/app/src/cli/commands/app/bulk/cancel.test.ts deleted file mode 100644 index cecdfc3ca40..00000000000 --- a/packages/app/src/cli/commands/app/bulk/cancel.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import BulkCancel from './cancel.js' -import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js' -import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' -import { - testAppLinked, - testOrganization, - testOrganizationApp, - testOrganizationStore, - testProject, -} from '../../../models/app/app.test-data.js' -import {describe, expect, test, vi, beforeEach} from 'vitest' - -vi.mock('../../../services/bulk-operations/cancel-bulk-operation.js') -vi.mock('../../../utilities/execute-command-helpers.js') - -describe('app bulk cancel command', () => { - const app = testAppLinked() - const remoteApp = testOrganizationApp() - const store = testOrganizationStore({shopDomain: 'shop.myshopify.com'}) - - beforeEach(() => { - vi.mocked(prepareAppStoreContext).mockResolvedValue({ - appContextResult: { - app, - remoteApp, - developerPlatformClient: remoteApp.developerPlatformClient, - organization: testOrganization(), - specifications: [], - project: testProject(), - activeConfig: {} as never, - }, - store, - }) - vi.mocked(cancelBulkOperation).mockResolvedValue() - }) - - test('prepares app/store context and cancels bulk operation', async () => { - // When - await BulkCancel.run(['--path', '/tmp/app', '--id', '12345', '--store', 'shop']) - - // Then - expect(prepareAppStoreContext).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/tmp/app', - id: '12345', - store: 'shop.myshopify.com', - }), - ) - expect(cancelBulkOperation).toHaveBeenCalledWith({ - organization: expect.any(Object), - storeFqdn: 'shop.myshopify.com', - operationId: 'gid://shopify/BulkOperation/12345', - remoteApp, - }) - }) - - test('passes full GID operation ID correctly', async () => { - // When - await BulkCancel.run(['--id', 'gid://shopify/BulkOperation/67890', '--store', 'shop']) - - // Then - expect(cancelBulkOperation).toHaveBeenCalledWith( - expect.objectContaining({ - operationId: 'gid://shopify/BulkOperation/67890', - }), - ) - }) -}) diff --git a/packages/app/src/cli/commands/app/bulk/execute.test.ts b/packages/app/src/cli/commands/app/bulk/execute.test.ts deleted file mode 100644 index f8b10e2d46d..00000000000 --- a/packages/app/src/cli/commands/app/bulk/execute.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import BulkExecute from './execute.js' -import {executeBulkOperation} from '../../../services/bulk-operations/execute-bulk-operation.js' -import {prepareExecuteContext} from '../../../utilities/execute-command-helpers.js' -import { - testAppLinked, - testOrganization, - testOrganizationApp, - testOrganizationStore, - testProject, -} from '../../../models/app/app.test-data.js' -import {describe, expect, test, vi, beforeEach} from 'vitest' - -vi.mock('../../../services/bulk-operations/execute-bulk-operation.js') -vi.mock('../../../utilities/execute-command-helpers.js') - -describe('app bulk execute command', () => { - const app = testAppLinked() - const remoteApp = testOrganizationApp() - const organization = testOrganization() - const store = testOrganizationStore({shopDomain: 'shop.myshopify.com'}) - - beforeEach(() => { - vi.mocked(prepareExecuteContext).mockResolvedValue({ - appContextResult: { - app, - remoteApp, - developerPlatformClient: remoteApp.developerPlatformClient, - organization, - specifications: [], - project: testProject(), - activeConfig: {} as never, - }, - store, - query: 'query { shop { name } }', - }) - vi.mocked(executeBulkOperation).mockResolvedValue() - }) - - test('prepares execution context and calls executeBulkOperation', async () => { - // When - await BulkExecute.run(['--query', 'query { shop { name } }', '--store', 'shop.myshopify.com']) - - // Then - expect(prepareExecuteContext).toHaveBeenCalledWith( - expect.objectContaining({ - query: 'query { shop { name } }', - store: 'shop.myshopify.com', - }), - ) - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, - remoteApp, - store, - query: 'query { shop { name } }', - variables: undefined, - variableFile: undefined, - watch: false, - outputFile: undefined, - }) - }) - - test('calls executeBulkOperation with variables flag', async () => { - // When - await BulkExecute.run([ - '--query', - 'query { shop { name } }', - '--store', - 'shop.myshopify.com', - '--variables', - '{"key": "value"}', - '--watch', - '--output-file', - 'output.json', - ]) - - // Then - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, - remoteApp, - store, - query: 'query { shop { name } }', - variables: ['{"key": "value"}'], - variableFile: undefined, - watch: true, - outputFile: 'output.json', - }) - }) - - test('calls executeBulkOperation with variable-file flag', async () => { - // When - await BulkExecute.run([ - '--query-file', - 'query.graphql', - '--store', - 'shop.myshopify.com', - '--variable-file', - 'variables.jsonl', - ]) - - // Then - expect(executeBulkOperation).toHaveBeenCalledWith({ - organization, - remoteApp, - store, - query: 'query { shop { name } }', - variables: undefined, - variableFile: expect.stringContaining('variables.jsonl'), - watch: false, - outputFile: undefined, - }) - }) -}) diff --git a/packages/app/src/cli/commands/app/bulk/status.test.ts b/packages/app/src/cli/commands/app/bulk/status.test.ts deleted file mode 100644 index c4fc061f7d2..00000000000 --- a/packages/app/src/cli/commands/app/bulk/status.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import BulkStatus from './status.js' -import {getBulkOperationStatus, listBulkOperations} from '../../../services/bulk-operations/bulk-operation-status.js' -import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js' -import { - testAppLinked, - testOrganization, - testOrganizationApp, - testOrganizationStore, - testProject, -} from '../../../models/app/app.test-data.js' -import {describe, expect, test, vi, beforeEach} from 'vitest' - -vi.mock('../../../services/bulk-operations/bulk-operation-status.js') -vi.mock('../../../utilities/execute-command-helpers.js') - -describe('app bulk status command', () => { - const app = testAppLinked() - const remoteApp = testOrganizationApp() - const organization = testOrganization() - const store = testOrganizationStore({shopDomain: 'shop.myshopify.com'}) - - beforeEach(() => { - vi.mocked(prepareAppStoreContext).mockResolvedValue({ - appContextResult: { - app, - remoteApp, - developerPlatformClient: remoteApp.developerPlatformClient, - organization, - specifications: [], - project: testProject(), - activeConfig: {} as never, - }, - store, - }) - vi.mocked(getBulkOperationStatus).mockResolvedValue() - vi.mocked(listBulkOperations).mockResolvedValue() - }) - - test('calls getBulkOperationStatus when id is provided', async () => { - // When - await BulkStatus.run(['--id', '123', '--store', 'shop.myshopify.com']) - - // Then - expect(prepareAppStoreContext).toHaveBeenCalledWith( - expect.objectContaining({ - id: '123', - store: 'shop.myshopify.com', - }), - ) - expect(getBulkOperationStatus).toHaveBeenCalledWith({ - organization, - storeFqdn: 'shop.myshopify.com', - operationId: 'gid://shopify/BulkOperation/123', - remoteApp, - }) - expect(listBulkOperations).not.toHaveBeenCalled() - }) - - test('calls listBulkOperations when id is not provided', async () => { - // When - await BulkStatus.run(['--store', 'shop.myshopify.com']) - - // Then - expect(prepareAppStoreContext).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - }), - ) - expect(listBulkOperations).toHaveBeenCalledWith({ - organization, - storeFqdn: 'shop.myshopify.com', - remoteApp, - }) - expect(getBulkOperationStatus).not.toHaveBeenCalled() - }) -}) diff --git a/packages/app/src/cli/commands/app/config/validate.test.ts b/packages/app/src/cli/commands/app/config/validate.test.ts index aa7ef84271c..98fdd12ce00 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -36,10 +36,6 @@ function mockHealthyProject() { } describe('app config validate command', () => { - test('keeps --client-id mutually exclusive with --config', () => { - expect(Validate.flags['client-id']?.exclusive).toEqual(['config']) - }) - test('calls validateApp with json: false by default', async () => { const app = testAppLinked() mockHealthyProject() @@ -76,48 +72,6 @@ describe('app config validate command', () => { await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) - test('skips active config prompts when --client-id is passed', async () => { - const app = testAppLinked() - mockHealthyProject() - vi.mocked(selectActiveConfig).mockResolvedValue({ - file: new TomlFile('shopify.app.staging.toml', {}), - } as any) - vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) - vi.mocked(validateApp).mockResolvedValue() - - await Validate.run(['--client-id', 'api-key'], import.meta.url) - - expect(selectActiveConfig).toHaveBeenCalledWith(expect.anything(), undefined, { - clientId: 'api-key', - skipPrompts: true, - }) - expect(linkedAppContext).toHaveBeenCalledWith({ - directory: expect.any(String), - clientId: 'api-key', - forceRelink: false, - userProvidedConfigName: 'shopify.app.staging.toml', - unsafeTolerateErrors: true, - }) - expect(validateApp).toHaveBeenCalledWith(app, {json: false}) - await expectValidationMetadataCalls({cmd_app_validate_json: false}) - }) - - test('keeps active config prompts enabled when --client-id is not passed', async () => { - const app = testAppLinked() - mockHealthyProject() - vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) - vi.mocked(validateApp).mockResolvedValue() - - await Validate.run([], import.meta.url) - - expect(selectActiveConfig).toHaveBeenCalledWith(expect.anything(), undefined, { - clientId: undefined, - skipPrompts: false, - }) - expect(validateApp).toHaveBeenCalledWith(app, {json: false}) - await expectValidationMetadataCalls({cmd_app_validate_json: false}) - }) - test('outputs JSON issues when active config has TOML parse errors', async () => { vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index d9b90837666..4da9ddc48ba 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -9,7 +9,6 @@ import metadata from '../../../metadata.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' -import {basename} from '@shopify/cli-kit/node/path' import {renderError} from '@shopify/cli-kit/node/ui' async function recordValidationFailure(issueCount: number, fileCount: number) { @@ -57,10 +56,7 @@ export default class Validate extends AppLinkedCommand { // Stage 2: Select active config and check for TOML parse errors scoped to it let activeConfig try { - activeConfig = await selectActiveConfig(project, flags.config, { - clientId: flags['client-id'], - skipPrompts: Boolean(flags['client-id']), - }) + activeConfig = await selectActiveConfig(project, flags.config) } catch (err) { if (err instanceof AbortError && flags.json) { await recordValidationFailure(1, 1) @@ -94,7 +90,7 @@ export default class Validate extends AppLinkedCommand { directory: flags.path, clientId: flags['client-id'], forceRelink: flags.reset, - userProvidedConfigName: flags.config ?? (flags['client-id'] ? basename(activeConfig.file.path) : undefined), + userProvidedConfigName: flags.config, unsafeTolerateErrors: true, }) app = context.app diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts deleted file mode 100644 index 26ac10c63ee..00000000000 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import Dev from './dev.js' -import {dev} from '../../services/dev.js' -import {linkedAppContext} from '../../services/app-context.js' -import {storeContext} from '../../services/store-context.js' -import {getTunnelMode} from '../../services/dev/tunnel-mode.js' -import {checkFolderIsValidApp} from '../../models/app/loader.js' -import { - testAppLinked, - testDeveloperPlatformClient, - testOrganization, - testOrganizationApp, - testOrganizationStore, - testProject, -} from '../../models/app/app.test-data.js' -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' -import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' -import {beforeEach, describe, expect, test, vi} from 'vitest' - -vi.mock('../../services/dev.js') -vi.mock('../../services/app-context.js') -vi.mock('../../services/store-context.js') -vi.mock('../../services/dev/tunnel-mode.js') -vi.mock('../../models/app/loader.js') -vi.mock('@shopify/cli-kit/node/metadata') - -describe('app dev command', () => { - beforeEach(() => { - vi.mocked(dev).mockReset() - vi.mocked(linkedAppContext).mockReset() - vi.mocked(storeContext).mockReset() - vi.mocked(getTunnelMode).mockReset() - vi.mocked(checkFolderIsValidApp).mockReset() - vi.mocked(addPublicMetadata).mockReset() - }) - - test('does not require --use-localhost when --install-mkcert is not passed', async () => { - await inTemporaryDirectory(async (tmp) => { - const app = testAppLinked({directory: tmp}) - const appContextResult = { - app, - remoteApp: testOrganizationApp(), - organization: testOrganization(), - project: testProject(), - activeConfig: {} as never, - specifications: [], - developerPlatformClient: testDeveloperPlatformClient(), - } as Awaited> - const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) - - vi.mocked(linkedAppContext).mockResolvedValue(appContextResult) - vi.mocked(storeContext).mockResolvedValue(store) - vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) - - await Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url) - - expect(getTunnelMode).toHaveBeenCalledWith({ - useLocalhost: false, - tunnelUrl: undefined, - localhostPort: undefined, - }) - expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}})) - }) - }) - - test('requires --use-localhost when --install-mkcert is passed', async () => { - await inTemporaryDirectory(async (tmp) => { - await expect(Dev.run(['--path', tmp, '--install-mkcert'], import.meta.url)).rejects.toThrow() - - expect(dev).not.toHaveBeenCalled() - }) - }) -}) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index ee6f217ee97..17edc962a05 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -54,14 +54,9 @@ export default class Dev extends AppLinkedCommand { description: "Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)", env: 'SHOPIFY_FLAG_USE_LOCALHOST', + default: false, exclusive: ['tunnel-url'], }), - 'install-mkcert': Flags.boolean({ - description: - 'Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.', - env: 'SHOPIFY_FLAG_INSTALL_MKCERT', - dependsOn: ['use-localhost'], - }), 'localhost-port': portFlag({ description: 'Port to use for localhost.', env: 'SHOPIFY_FLAG_LOCALHOST_PORT', @@ -75,10 +70,6 @@ export default class Dev extends AppLinkedCommand { description: 'Local port of the theme app extension development server.', env: 'SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT', }), - 'store-password': Flags.string({ - description: 'The password for storefronts with password protection.', - env: 'SHOPIFY_FLAG_STORE_PASSWORD', - }), notify: Flags.string({ description: 'The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.', @@ -105,7 +96,7 @@ export default class Dev extends AppLinkedCommand { const {flags} = await this.parse(Dev) const tunnelMode = await getTunnelMode({ - useLocalhost: flags['use-localhost'] ?? false, + useLocalhost: flags['use-localhost'], tunnelUrl: flags['tunnel-url'], localhostPort: flags['localhost-port'], }) @@ -143,11 +134,9 @@ export default class Dev extends AppLinkedCommand { checkoutCartUrl: flags['checkout-cart-url'], theme: flags.theme, themeExtensionPort: flags['theme-app-extension-port'], - storePassword: flags['store-password'], notify: flags.notify, graphiqlPort: flags['graphiql-port'], graphiqlKey: flags['graphiql-key'], - installMkcert: flags['install-mkcert'] ?? false, tunnel: tunnelMode, } diff --git a/packages/app/src/cli/commands/app/init.test.ts b/packages/app/src/cli/commands/app/init.test.ts index caa6f51db63..2be8f0daeec 100644 --- a/packages/app/src/cli/commands/app/init.test.ts +++ b/packages/app/src/cli/commands/app/init.test.ts @@ -14,7 +14,7 @@ import { } from '../../models/app/app.test-data.js' import {describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs' import {inferPackageManager} from '@shopify/cli-kit/node/node-package-manager' vi.mock('../../prompts/init/init.js') @@ -30,278 +30,241 @@ vi.mock('../../services/dev/fetch.js', async (importOriginal) => { }) vi.mock('../../prompts/dev.js') vi.mock('../../services/init/validate.js') +vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/node-package-manager') describe('Init command', () => { test('runs init command with default flags', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const mockOrganization = testOrganization() - const mockDeveloperPlatformClient = testDeveloperPlatformClient() - const mockApp = testAppLinked() + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(generateRandomNameForSubdirectory).mockResolvedValue('test-app') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + vi.mocked(selectOrg).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method on the developer platform client + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [], + hasMorePages: false, + }) - mockAndCaptureOutput() - vi.mocked(validateTemplateValue).mockReturnValue(undefined) - vi.mocked(validateFlavorValue).mockReturnValue(undefined) - vi.mocked(inferPackageManager).mockReturnValue('npm') - vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) - vi.mocked(selectOrg).mockResolvedValue(mockOrganization) + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, + }) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) + vi.mocked(appNamePrompt).mockResolvedValue('test-app') + vi.mocked(initService).mockResolvedValue({app: mockApp}) + + // When + await Init.run([]) + + // Then + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-app', + packageManager: 'npm', + }), + ) + }) - // Mock the orgAndApps method on the developer platform client - vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ - organization: mockOrganization, - apps: [], - hasMorePages: false, - }) + test('runs init command without prompts when organization-id, name, and template flags are provided', async () => { + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + + // Mock fetchOrgFromId to return the organization + vi.mocked(fetchOrgFromId).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method on the developer platform client + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [], + hasMorePages: false, + }) - vi.mocked(initPrompt).mockResolvedValue({ - template: 'https://github.com/Shopify/shopify-app-template-remix', - templateType: 'remix', - globalCLIResult: {install: false, alreadyInstalled: false}, - }) - vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) - vi.mocked(appNamePrompt).mockResolvedValue('test-app') - vi.mocked(initService).mockResolvedValue({app: mockApp}) - - // When - await Init.run(['--path', tmpDir]) - - // Then - expect(initService).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'test-app', - packageManager: 'npm', - }), - ) + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, }) + vi.mocked(initService).mockResolvedValue({app: mockApp}) + + // When + await Init.run(['--organization-id', mockOrganization.id, '--name', 'my-app', '--template', 'remix']) + + // Then + // Verify that prompt functions were NOT called + // Any other interactive prompts would also cause the test to fail with an AbortError + expect(selectOrg).not.toHaveBeenCalled() + expect(createAsNewAppPrompt).not.toHaveBeenCalled() + expect(appNamePrompt).not.toHaveBeenCalled() + + // Verify the command completed successfully + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-app', + packageManager: 'npm', + template: 'https://github.com/Shopify/shopify-app-template-remix', + }), + ) }) - test('runs init command without prompts when organization-id, name, and template flags are provided', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const mockOrganization = testOrganization() - const mockDeveloperPlatformClient = testDeveloperPlatformClient() - const mockApp = testAppLinked() + test('fails with clear error message when invalid organization-id is provided', async () => { + // Given + const mockDeveloperPlatformClient = testDeveloperPlatformClient() - mockAndCaptureOutput() + // Suppress stderr output for this error test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const outputMock = mockAndCaptureOutput() vi.mocked(validateTemplateValue).mockReturnValue(undefined) vi.mocked(validateFlavorValue).mockReturnValue(undefined) vi.mocked(inferPackageManager).mockReturnValue('npm') vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) - // Mock fetchOrgFromId to return the organization - vi.mocked(fetchOrgFromId).mockResolvedValue(mockOrganization) - - // Mock the orgAndApps method on the developer platform client - vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ - organization: mockOrganization, - apps: [], - hasMorePages: false, - }) + // Mock fetchOrgFromId to throw NoOrgError for invalid organization + vi.mocked(fetchOrgFromId).mockRejectedValue( + new NoOrgError({type: 'UserAccount', email: 'test@example.com'}, 'invalid-org-id'), + ) vi.mocked(initPrompt).mockResolvedValue({ template: 'https://github.com/Shopify/shopify-app-template-remix', templateType: 'remix', globalCLIResult: {install: false, alreadyInstalled: false}, }) - vi.mocked(initService).mockResolvedValue({app: mockApp}) - - // When - await Init.run([ - '--organization-id', - mockOrganization.id, - '--name', - 'my-app', - '--template', - 'remix', - '--path', - tmpDir, - ]) - - // Then - // Verify that prompt functions were NOT called - // Any other interactive prompts would also cause the test to fail with an AbortError - expect(selectOrg).not.toHaveBeenCalled() - expect(createAsNewAppPrompt).not.toHaveBeenCalled() - expect(appNamePrompt).not.toHaveBeenCalled() - - // Verify the command completed successfully - expect(initService).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my-app', - packageManager: 'npm', - template: 'https://github.com/Shopify/shopify-app-template-remix', - }), - ) - }) + + // When/Then + // The command throws an AbortError which is caught by oclif's error handler + // This causes process.exit(1) which vitest intercepts + await expect( + Init.run(['--organization-id', 'invalid-org-id', '--name', 'my-app', '--template', 'remix']), + ).rejects.toThrow('process.exit unexpectedly called with "1"') + + // Verify the error message was displayed + expect(outputMock.error()).toContain('No Organization found') + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + // Always restore console.error, even if the test fails + consoleErrorSpy.mockRestore() + } }) - test('fails with clear error message when invalid organization-id is provided', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const mockDeveloperPlatformClient = testDeveloperPlatformClient() - - // Suppress stderr output for this error test - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - try { - const outputMock = mockAndCaptureOutput() - vi.mocked(validateTemplateValue).mockReturnValue(undefined) - vi.mocked(validateFlavorValue).mockReturnValue(undefined) - vi.mocked(inferPackageManager).mockReturnValue('npm') - vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) - - // Mock fetchOrgFromId to throw NoOrgError for invalid organization - vi.mocked(fetchOrgFromId).mockRejectedValue( - new NoOrgError({type: 'UserAccount', email: 'test@example.com'}, 'invalid-org-id'), - ) - - vi.mocked(initPrompt).mockResolvedValue({ - template: 'https://github.com/Shopify/shopify-app-template-remix', - templateType: 'remix', - globalCLIResult: {install: false, alreadyInstalled: false}, - }) - - // When/Then - // The command throws an AbortError which is caught by oclif's error handler - // This causes process.exit(1) which vitest intercepts - await expect( - Init.run([ - '--organization-id', - 'invalid-org-id', - '--name', - 'my-app', - '--template', - 'remix', - '--path', - tmpDir, - ]), - ).rejects.toThrow('process.exit unexpectedly called with "1"') - - // Verify the error message was displayed - expect(outputMock.error()).toContain('No Organization found') - - // Verify initService was never called since validation failed - expect(initService).not.toHaveBeenCalled() - } finally { - // Always restore console.error, even if the test fails - consoleErrorSpy.mockRestore() - } + test('skips app selection prompts when organization has existing apps but --name flag is provided', async () => { + // Given + const mockOrganization = testOrganization() + const mockDeveloperPlatformClient = testDeveloperPlatformClient() + const mockApp = testAppLinked() + const existingApp = testOrganizationApp() + + mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + vi.mocked(inferPackageManager).mockReturnValue('npm') + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) + + // Mock fetchOrgFromId to return the organization + vi.mocked(fetchOrgFromId).mockResolvedValue(mockOrganization) + + // Mock the orgAndApps method to return existing apps + vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ + organization: mockOrganization, + apps: [existingApp], + hasMorePages: false, }) + + vi.mocked(initPrompt).mockResolvedValue({ + template: 'https://github.com/Shopify/shopify-app-template-remix', + templateType: 'remix', + globalCLIResult: {install: false, alreadyInstalled: false}, + }) + vi.mocked(initService).mockResolvedValue({app: mockApp}) + + // When + await Init.run(['--organization-id', mockOrganization.id, '--name', 'my-new-app', '--template', 'remix']) + + // Then + // Verify that app selection prompts were NOT called even though org has existing apps + expect(selectOrg).not.toHaveBeenCalled() + expect(createAsNewAppPrompt).not.toHaveBeenCalled() + expect(selectAppPrompt).not.toHaveBeenCalled() + expect(appNamePrompt).not.toHaveBeenCalled() + + // Verify the command completed successfully with the provided name + expect(initService).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-new-app', + packageManager: 'npm', + template: 'https://github.com/Shopify/shopify-app-template-remix', + }), + ) }) - test('skips app selection prompts when organization has existing apps but --name flag is provided', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const mockOrganization = testOrganization() - const mockDeveloperPlatformClient = testDeveloperPlatformClient() - const mockApp = testAppLinked() - const existingApp = testOrganizationApp() - - mockAndCaptureOutput() + test('fails with clear error message when --name flag is empty', async () => { + // Given + // Suppress stderr output for this error test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const outputMock = mockAndCaptureOutput() vi.mocked(validateTemplateValue).mockReturnValue(undefined) vi.mocked(validateFlavorValue).mockReturnValue(undefined) - vi.mocked(inferPackageManager).mockReturnValue('npm') - vi.mocked(selectDeveloperPlatformClient).mockReturnValue(mockDeveloperPlatformClient) - // Mock fetchOrgFromId to return the organization - vi.mocked(fetchOrgFromId).mockResolvedValue(mockOrganization) - - // Mock the orgAndApps method to return existing apps - vi.mocked(mockDeveloperPlatformClient.orgAndApps).mockResolvedValue({ - organization: mockOrganization, - apps: [existingApp], - hasMorePages: false, - }) - - vi.mocked(initPrompt).mockResolvedValue({ - template: 'https://github.com/Shopify/shopify-app-template-remix', - templateType: 'remix', - globalCLIResult: {install: false, alreadyInstalled: false}, - }) - vi.mocked(initService).mockResolvedValue({app: mockApp}) - - // When - await Init.run([ - '--organization-id', - mockOrganization.id, - '--name', - 'my-new-app', - '--template', - 'remix', - '--path', - tmpDir, - ]) - - // Then - // Verify that app selection prompts were NOT called even though org has existing apps - expect(selectOrg).not.toHaveBeenCalled() - expect(createAsNewAppPrompt).not.toHaveBeenCalled() - expect(selectAppPrompt).not.toHaveBeenCalled() - expect(appNamePrompt).not.toHaveBeenCalled() - - // Verify the command completed successfully with the provided name - expect(initService).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my-new-app', - packageManager: 'npm', - template: 'https://github.com/Shopify/shopify-app-template-remix', - }), + // When/Then + await expect(Init.run(['--name', '', '--template', 'remix'])).rejects.toThrow( + 'process.exit unexpectedly called with "1"', ) - }) - }) - test('fails with clear error message when --name flag is empty', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - // Suppress stderr output for this error test - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - try { - const outputMock = mockAndCaptureOutput() - vi.mocked(validateTemplateValue).mockReturnValue(undefined) - vi.mocked(validateFlavorValue).mockReturnValue(undefined) - - // When/Then - await expect(Init.run(['--name', '', '--template', 'remix', '--path', tmpDir])).rejects.toThrow( - 'process.exit unexpectedly called with "1"', - ) - - // Verify the error message was displayed - expect(outputMock.error()).toContain("The --name flag can't be empty") - - // Verify initService was never called since validation failed - expect(initService).not.toHaveBeenCalled() - } finally { - consoleErrorSpy.mockRestore() - } - }) + // Verify the error message was displayed + expect(outputMock.error()).toContain("The --name flag can't be empty") + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } }) test('fails with clear error message when --name flag is whitespace only', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - // Suppress stderr output for this error test - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - try { - const outputMock = mockAndCaptureOutput() - vi.mocked(validateTemplateValue).mockReturnValue(undefined) - vi.mocked(validateFlavorValue).mockReturnValue(undefined) - - // When/Then - await expect(Init.run(['--name', ' ', '--template', 'remix', '--path', tmpDir])).rejects.toThrow( - 'process.exit unexpectedly called with "1"', - ) - - // Verify the error message was displayed - expect(outputMock.error()).toContain("The --name flag can't be empty") - - // Verify initService was never called since validation failed - expect(initService).not.toHaveBeenCalled() - } finally { - consoleErrorSpy.mockRestore() - } - }) + // Given + // Suppress stderr output for this error test + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const outputMock = mockAndCaptureOutput() + vi.mocked(validateTemplateValue).mockReturnValue(undefined) + vi.mocked(validateFlavorValue).mockReturnValue(undefined) + + // When/Then + await expect(Init.run(['--name', ' ', '--template', 'remix'])).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ) + + // Verify the error message was displayed + expect(outputMock.error()).toContain("The --name flag can't be empty") + + // Verify initService was never called since validation failed + expect(initService).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } }) }) diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index 53d2e66f0fd..b60f0b612c4 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -41,6 +41,7 @@ import { } from '../../utilities/developer-platform-client.js' import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../../api/graphql/app_deploy.js' +import {ConvertDevToTransferDisabledStoreVariables} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {SendSampleWebhookSchema, SendSampleWebhookVariables} from '../../services/webhook/request-sample.js' import {PublicApiVersionsSchema} from '../../services/webhook/request-api-versions.js' import {WebhookTopicsSchema, WebhookTopicsVariables} from '../../services/webhook/request-topics.js' @@ -1240,6 +1241,13 @@ const generateSignedUploadUrlResponse: AssetUrlSchema = { userErrors: [], } +const convertedToTransferDisabledStoreResponse = { + convertDevToTestStore: { + convertedToTestStore: true, + userErrors: [], + }, +} + const organizationsResponse: Organization[] = [testOrganization()] const sendSampleWebhookResponse: SendSampleWebhookSchema = { @@ -1331,6 +1339,8 @@ export function testDeveloperPlatformClient(stubs: Partial Promise.resolve(deployResponse), release: (_input: {app: MinimalAppIdentifiers; version: AppVersionIdentifiers}) => Promise.resolve(releaseResponse), generateSignedUploadUrl: (_app: MinimalAppIdentifiers) => Promise.resolve(generateSignedUploadUrlResponse), + convertToTransferDisabledStore: (_input: ConvertDevToTransferDisabledStoreVariables) => + Promise.resolve(convertedToTransferDisabledStoreResponse), sendSampleWebhook: (_input: SendSampleWebhookVariables) => Promise.resolve(sendSampleWebhookResponse), apiVersions: () => Promise.resolve(apiVersionsResponse), topics: (_input: WebhookTopicsVariables) => Promise.resolve(topicsResponse), diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index 89c4e4ffd64..ed48a11f709 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -651,7 +651,12 @@ describe('manifest', () => { }) // When - const manifest = await app.manifest({app_access: 'UUID_A'}) + const manifest = await app.manifest({ + app: 'API_KEY', + extensions: {app_access: 'UUID_A'}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + }) // Then expect(manifest).toEqual({ diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 73eec01f3fc..c2fa8ddf53a 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -1,6 +1,6 @@ import {AppErrors, isWebType} from './loader.js' import {ensurePathStartsWithSlash} from './validation/common.js' -import {ExtensionUuidsByLocalIdentifier} from './identifiers.js' +import {Identifiers} from './identifiers.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {FunctionConfigType} from '../extensions/specifications/function.js' import {ExtensionSpecification, RemoteAwareExtensionSpecification} from '../extensions/specification.js' @@ -245,7 +245,7 @@ export interface AppInterface< * If creating an app on the platform based on this app and its configuration, what default options should the app take? */ creationDefaultOptions(): CreateAppOptions - manifest: (appModuleUuids: ExtensionUuidsByLocalIdentifier | undefined) => Promise + manifest: (identifiers: Identifiers | undefined) => Promise removeExtension: (extensionUid: string) => void updateHiddenConfig: (values: Partial) => Promise setDevApplicationURLs: (devApplicationURLs: ApplicationURLs) => void @@ -331,7 +331,7 @@ export class App< this.realExtensions.forEach((ext) => ext.patchWithAppDevURLs(devApplicationURLs)) } - async manifest(appModuleUuids: ExtensionUuidsByLocalIdentifier | undefined): Promise { + async manifest(identifiers: Identifiers | undefined): Promise { const modules = await Promise.all( this.realExtensions.map(async (module) => { const config = await module.deployConfig({ @@ -342,7 +342,7 @@ export class App< type: module.externalType, handle: module.handle, uid: module.uid, - uuid: appModuleUuids?.[module.localIdentifier], + uuid: identifiers?.extensions[module.localIdentifier], assets: module.uid, target: module.contextValue, config: (config ?? {}) as JsonMapType, diff --git a/packages/app/src/cli/models/app/identifiers.ts b/packages/app/src/cli/models/app/identifiers.ts index d0caebcf0f8..ed38e1ecdb0 100644 --- a/packages/app/src/cli/models/app/identifiers.ts +++ b/packages/app/src/cli/models/app/identifiers.ts @@ -30,10 +30,6 @@ export interface Identifiers { extensionsNonUuidManaged: IdentifiersExtensions } -export interface ExtensionUuidsByLocalIdentifier { - [localIdentifier: string]: string -} - type UuidOnlyIdentifiers = Omit type UpdateAppIdentifiersCommand = 'dev' | 'deploy' | 'release' | 'import-extensions' interface UpdateAppIdentifiersOptions { diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 99191649959..bfb1a1680fe 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -233,11 +233,16 @@ describe('deployConfig', async () => { }) describe('bundleConfig', async () => { - test('returns the uuid from appModuleUuids', async () => { + test('returns the uuid from extensions when the extension is uuid managed', async () => { const extensionInstance = await testThemeExtensions() const got = await extensionInstance.bundleConfig({ - appModuleUuids: {'theme-extension-name': 'theme-uuid'}, + identifiers: { + extensions: {'theme-extension-name': 'theme-uuid'}, + extensionIds: {}, + app: 'My app', + extensionsNonUuidManaged: {}, + }, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -255,7 +260,12 @@ describe('bundleConfig', async () => { const extensionInstance = await testPaymentExtensions() const got = await extensionInstance.bundleConfig({ - appModuleUuids: {'payment-extension-name': 'payment-uuid'}, + identifiers: { + extensions: {'payment-extension-name': 'payment-uuid'}, + extensionIds: {}, + app: 'My app', + extensionsNonUuidManaged: {}, + }, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -269,11 +279,16 @@ describe('bundleConfig', async () => { ) }) - test('returns the uuid for a non-uuid-managed extension', async () => { + test('returns the uuid from extensionsNonUuidManaged when the extension is not uuid managed', async () => { const extensionInstance = await testAppConfigExtensions() const got = await extensionInstance.bundleConfig({ - appModuleUuids: {point_of_sale: 'uuid'}, + identifiers: { + extensions: {}, + extensionIds: {}, + app: 'My app', + extensionsNonUuidManaged: {point_of_sale: 'uuid'}, + }, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -401,7 +416,7 @@ describe('buildUIDFromStrategy', async () => { // Given const extensionInstance = await testSingleWebhookSubscriptionExtension() // Then - expect(extensionInstance.uid).toBe('orders/delete::::https://my-app.com/webhooks') + expect(extensionInstance.uid).toBe('orders/delete::undefined::https://my-app.com/webhooks') }) test('returns a custom string when strategy is dynamic and it is a webhook subscription extension with filters', async () => { diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 23ae5cb345c..f651bfd5c12 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -3,7 +3,7 @@ import {FunctionConfigType} from './specifications/function.js' import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' import {ExtensionBuildOptions} from '../../services/build/extension.js' -import {ExtensionUuidsByLocalIdentifier} from '../app/identifiers.js' +import {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfiguration} from '../app/app.js' import {ApplicationURLs} from '../../services/dev/urls.js' @@ -245,8 +245,10 @@ export class ExtensionInstance { }) }) -describe('findAllImportedFiles', () => { - test('ignores commented-out imports', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const entryPath = joinPath(tmpDir, 'index.ts') - const commentedPath = joinPath(tmpDir, 'commented.ts') - - await writeFile( - entryPath, - ` - // import './commented.ts' - /* - import './commented.ts' - */ - `, - ) - await writeFile(commentedPath, `export const commented = true`) - - const importedFiles = (await findAllImportedFiles(entryPath)).map((file) => normalizePath(file)) - - expect(importedFiles).not.toContain(normalizePath(commentedPath)) - }) - }) - - test('does not follow type-only imports and exports', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const entryPath = joinPath(tmpDir, 'index.ts') - const valuePath = joinPath(tmpDir, 'value.ts') - const valueNestedPath = joinPath(tmpDir, 'value-nested.ts') - const mixedValuePath = joinPath(tmpDir, 'mixed-value.ts') - const mixedExportPath = joinPath(tmpDir, 'mixed-export.ts') - const typePath = joinPath(tmpDir, 'types.ts') - const typeNestedPath = joinPath(tmpDir, 'type-nested.ts') - const exportedTypePath = joinPath(tmpDir, 'exported-types.ts') - const exportedTypeNestedPath = joinPath(tmpDir, 'exported-type-nested.ts') - const emptyImportPath = joinPath(tmpDir, 'empty-import.ts') - const emptyImportNestedPath = joinPath(tmpDir, 'empty-import-nested.ts') - const emptyExportPath = joinPath(tmpDir, 'empty-export.ts') - const emptyExportNestedPath = joinPath(tmpDir, 'empty-export-nested.ts') - - await writeFile( - entryPath, - ` - import './value.ts' - import MixedValue, { type MixedType } from './mixed-value.ts' - import {} from './empty-import.ts' - import type { TypeOnly } from './types.ts' - export { mixedValue, type MixedExportType } from './mixed-export.ts' - export {} from './empty-export.ts' - export type { ExportedTypeOnly } from './exported-types.ts' - `, - ) - await writeFile(valuePath, `import './value-nested.ts'`) - await writeFile(valueNestedPath, `export const valueNested = true`) - await writeFile(mixedValuePath, `export default true; export type MixedType = string`) - await writeFile(mixedExportPath, `export const mixedValue = true; export type MixedExportType = string`) - await writeFile(typePath, `import './type-nested.ts'; export type TypeOnly = string`) - await writeFile(typeNestedPath, `export const typeNested = true`) - await writeFile(exportedTypePath, `import './exported-type-nested.ts'; export type ExportedTypeOnly = string`) - await writeFile(exportedTypeNestedPath, `export const exportedTypeNested = true`) - await writeFile(emptyImportPath, `import './empty-import-nested.ts'`) - await writeFile(emptyImportNestedPath, `export const emptyImportNested = true`) - await writeFile(emptyExportPath, `import './empty-export-nested.ts'`) - await writeFile(emptyExportNestedPath, `export const emptyExportNested = true`) - - const importedFiles = (await findAllImportedFiles(entryPath)).map((file) => normalizePath(file)) - - expect(importedFiles).toContain(normalizePath(valuePath)) - expect(importedFiles).toContain(normalizePath(valueNestedPath)) - expect(importedFiles).toContain(normalizePath(mixedValuePath)) - expect(importedFiles).toContain(normalizePath(mixedExportPath)) - expect(importedFiles).toContain(normalizePath(emptyImportPath)) - expect(importedFiles).toContain(normalizePath(emptyImportNestedPath)) - expect(importedFiles).toContain(normalizePath(emptyExportPath)) - expect(importedFiles).toContain(normalizePath(emptyExportNestedPath)) - expect(importedFiles).not.toContain(normalizePath(typePath)) - expect(importedFiles).not.toContain(normalizePath(typeNestedPath)) - expect(importedFiles).not.toContain(normalizePath(exportedTypePath)) - expect(importedFiles).not.toContain(normalizePath(exportedTypeNestedPath)) - }) - }) - - test('includes imports outside the boundary directory without recursively scanning them', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const extensionDir = joinPath(tmpDir, 'extensions', 'extension') - const srcDir = joinPath(extensionDir, 'src') - const sharedDir = joinPath(tmpDir, 'shared') - - await mkdir(extensionDir) - await mkdir(srcDir) - await mkdir(sharedDir) - - const entryPath = joinPath(srcDir, 'index.ts') - const localPath = joinPath(srcDir, 'local.ts') - const nestedPath = joinPath(srcDir, 'nested.ts') - const externalPath = joinPath(sharedDir, 'utils.ts') - const externalNestedPath = joinPath(sharedDir, 'secret.ts') - - await writeFile( - entryPath, - ` - import './local.ts' - import '../../../shared/utils.ts' - `, - ) - await writeFile(localPath, `import './nested.ts'`) - await writeFile(nestedPath, `export const nested = true`) - await writeFile(externalPath, `import './secret.ts'`) - await writeFile(externalNestedPath, `export const secret = true`) - - const importedFiles = (await findAllImportedFiles(entryPath, {boundaryDirectory: extensionDir})).map((file) => - normalizePath(file), - ) - - expect(importedFiles).toContain(normalizePath(localPath)) - expect(importedFiles).toContain(normalizePath(nestedPath)) - expect(importedFiles).toContain(normalizePath(externalPath)) - expect(importedFiles).not.toContain(normalizePath(externalNestedPath)) - }) - }) - - test('recursively scans imports outside the boundary directory when explicitly included by tsconfig', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const extensionDir = joinPath(tmpDir, 'extensions', 'extension') - const srcDir = joinPath(extensionDir, 'src') - const sharedDir = joinPath(tmpDir, 'shared') - - await mkdir(extensionDir) - await mkdir(srcDir) - await mkdir(sharedDir) - - const entryPath = joinPath(srcDir, 'index.ts') - const localPath = joinPath(srcDir, 'local.ts') - const externalPath = joinPath(sharedDir, 'utils.ts') - const externalNestedPath = joinPath(sharedDir, 'secret.ts') - - await writeFile( - joinPath(tmpDir, 'tsconfig.json'), - JSON.stringify({ - include: ['extensions/extension/src/**/*', 'shared/**/*'], - }), - ) - await writeFile( - entryPath, - ` - import './local.ts' - import '../../../shared/utils.ts' - `, - ) - await writeFile(localPath, `export const local = true`) - await writeFile(externalPath, `import './secret.ts'`) - await writeFile(externalNestedPath, `export const secret = true`) - - const allowedFiles = await findExplicitTsConfigFiles(entryPath, extensionDir) - const importedFiles = ( - await findAllImportedFiles(entryPath, { - boundaryDirectory: extensionDir, - allowedFiles, - alwaysAllowedFiles: new Set([entryPath]), - }) - ).map((file) => normalizePath(file)) - - expect(allowedFiles).toContain(normalizePath(externalPath)) - expect(importedFiles).toContain(normalizePath(localPath)) - expect(importedFiles).toContain(normalizePath(externalPath)) - expect(importedFiles).toContain(normalizePath(externalNestedPath)) - }) - }) - - test('only follows files from an explicit tsconfig include list when provided', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const srcDir = joinPath(tmpDir, 'src') - const excludedDir = joinPath(tmpDir, 'excluded') - - await mkdir(srcDir) - await mkdir(excludedDir) - - const entryPath = joinPath(srcDir, 'index.ts') - const includedPath = joinPath(srcDir, 'included.ts') - const includedNestedPath = joinPath(srcDir, 'included-nested.ts') - const excludedPath = joinPath(excludedDir, 'excluded.ts') - const excludedNestedPath = joinPath(excludedDir, 'excluded-nested.ts') - - await writeFile( - joinPath(tmpDir, 'tsconfig.json'), - JSON.stringify({ - include: ['src/**/*'], - }), - ) - await writeFile( - entryPath, - ` - import './included.ts' - import '../excluded/excluded.ts' - `, - ) - await writeFile(includedPath, `import './included-nested.ts'`) - await writeFile(includedNestedPath, `export const includedNested = true`) - await writeFile(excludedPath, `import './excluded-nested.ts'`) - await writeFile(excludedNestedPath, `export const excludedNested = true`) - - const allowedFiles = await findExplicitTsConfigFiles(entryPath, tmpDir) - const importedFiles = ( - await findAllImportedFiles(entryPath, { - boundaryDirectory: tmpDir, - allowedFiles, - alwaysAllowedFiles: new Set([entryPath]), - }) - ).map((file) => normalizePath(file)) - - expect(importedFiles).toContain(normalizePath(includedPath)) - expect(importedFiles).toContain(normalizePath(includedNestedPath)) - expect(importedFiles).not.toContain(normalizePath(excludedPath)) - expect(importedFiles).not.toContain(normalizePath(excludedNestedPath)) - }) - }) - - test('uses explicit tsconfig include inherited from an extended config', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const extensionDir = joinPath(tmpDir, 'extensions', 'extension') - const srcDir = joinPath(extensionDir, 'src') - const excludedDir = joinPath(extensionDir, 'excluded') - - await mkdir(extensionDir) - await mkdir(srcDir) - await mkdir(excludedDir) - - const entryPath = joinPath(srcDir, 'index.ts') - const includedPath = joinPath(srcDir, 'included.ts') - const excludedPath = joinPath(excludedDir, 'excluded.ts') - - await writeFile( - joinPath(tmpDir, 'tsconfig.base.json'), - JSON.stringify({ - include: ['extensions/extension/src/**/*'], - }), - ) - await writeFile( - joinPath(extensionDir, 'tsconfig.json'), - JSON.stringify({ - extends: '../../tsconfig.base.json', - }), - ) - await writeFile( - entryPath, - ` - import './included.ts' - import '../excluded/excluded.ts' - `, - ) - await writeFile(includedPath, `export const included = true`) - await writeFile(excludedPath, `export const excluded = true`) - - const allowedFiles = await findExplicitTsConfigFiles(entryPath, extensionDir) - const importedFiles = ( - await findAllImportedFiles(entryPath, { - boundaryDirectory: extensionDir, - allowedFiles, - alwaysAllowedFiles: new Set([entryPath]), - }) - ).map((file) => normalizePath(file)) - - expect(allowedFiles).toContain(normalizePath(entryPath)) - expect(importedFiles).toContain(normalizePath(includedPath)) - expect(importedFiles).not.toContain(normalizePath(excludedPath)) - }) - }) - - test('does not follow declaration files', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const entryPath = joinPath(tmpDir, 'index.ts') - const declarationPath = joinPath(tmpDir, 'types.d.ts') - const nestedPath = joinPath(tmpDir, 'nested.ts') - - await writeFile(entryPath, `import './types.d.ts'`) - await writeFile(declarationPath, `import './nested.ts'`) - await writeFile(nestedPath, `export const nested = true`) - - const importedFiles = (await findAllImportedFiles(entryPath, {boundaryDirectory: tmpDir})).map((file) => - normalizePath(file), - ) - - expect(importedFiles).not.toContain(normalizePath(declarationPath)) - expect(importedFiles).not.toContain(normalizePath(nestedPath)) - }) - }) - - test('does not use a tsconfig allowlist when files and include are implicit', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const entryPath = joinPath(tmpDir, 'index.ts') - - await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') - await writeFile(entryPath, `export const entry = true`) - - await expect(findExplicitTsConfigFiles(entryPath, tmpDir)).resolves.toBeUndefined() - }) - }) - - test('uses cached imports when the same file is scanned more than once', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const entryPath = joinPath(tmpDir, 'index.ts') - const localPath = joinPath(tmpDir, 'local.ts') - const nestedPath = joinPath(tmpDir, 'nested.ts') - - await writeFile(entryPath, `import './local.ts'`) - await writeFile(localPath, `import './nested.ts'`) - await writeFile(nestedPath, `export const nested = true`) - - const readFileSync = vi.spyOn(fs, 'readFileSync') - const importCache = new Map() - - await findAllImportedFiles(entryPath, {boundaryDirectory: tmpDir, importCache}) - await findAllImportedFiles(entryPath, {boundaryDirectory: tmpDir, importCache}) - - const readCountsByPath = new Map() - for (const [path] of readFileSync.mock.calls) { - readCountsByPath.set(path, (readCountsByPath.get(path) ?? 0) + 1) - } - - expect(readCountsByPath.get(entryPath)).toBe(1) - expect(readCountsByPath.get(localPath)).toBe(1) - expect(readCountsByPath.get(nestedPath)).toBe(1) - }) - }) -}) - describe('createIntentsTypeDefinition', () => { test('returns empty string when intents array is empty', async () => { // When diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index 367a962e26d..32da0e44a6d 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -1,5 +1,5 @@ import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs' -import {dirname, isSubpath, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path' +import {dirname, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {compile} from 'json-schema-to-typescript' import {pascalize} from '@shopify/cli-kit/common/string' @@ -46,44 +46,23 @@ export function parseApiVersion(apiVersion: string): {year: number; month: numbe return {year: parseInt(year, 10), month: parseInt(month, 10)} } -interface LoadedTsConfig { - compilerOptions: ts.CompilerOptions - configPath: string | undefined - fileNames?: string[] - hasExplicitFiles: boolean -} - -export type TsConfigCache = Map - -async function loadTsConfig(startPath: string, cache?: TsConfigCache): Promise { +async function loadTsConfig( + startPath: string, +): Promise<{compilerOptions: ts.CompilerOptions; configPath: string | undefined}> { const ts = await loadTypeScript() const configPath = ts.findConfigFile(startPath, ts.sys.fileExists.bind(ts.sys), 'tsconfig.json') if (!configPath) { - return {compilerOptions: {}, configPath: undefined, hasExplicitFiles: false} - } - - const resolvedConfigPath = resolvePath(configPath) - const cachedConfig = cache?.get(resolvedConfigPath) - if (cachedConfig) { - return cachedConfig + return {compilerOptions: {}, configPath: undefined} } const configFile = ts.readConfigFile(configPath, ts.sys.readFile.bind(ts.sys)) if (configFile.error) { - return {compilerOptions: {}, configPath: resolvedConfigPath, hasExplicitFiles: false} + return {compilerOptions: {}, configPath} } const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configPath)) - const hasExplicitFiles = Boolean(parsedConfig.raw?.files ?? parsedConfig.raw?.include) - const loadedConfig = { - compilerOptions: parsedConfig.options, - configPath: resolvedConfigPath, - fileNames: parsedConfig.fileNames, - hasExplicitFiles, - } - cache?.set(resolvedConfigPath, loadedConfig) - return loadedConfig + return {compilerOptions: parsedConfig.options, configPath} } async function fallbackResolve(importPath: string, baseDir: string): Promise { @@ -116,85 +95,14 @@ async function fallbackResolve(importPath: string, baseDir: string): Promise - alwaysAllowedFiles?: Set - importCache?: Map - tsConfigCache?: TsConfigCache -} - -interface ParseAndResolveImportsOptions { - importCache?: Map - tsConfigCache?: TsConfigCache -} - -function hasOnlyTypeOnlyElements(elements: ReadonlyArray<{isTypeOnly: boolean}>): boolean { - return elements.length > 0 && elements.every((element) => element.isTypeOnly) -} - -function isTypeOnlyImport(node: ts.ImportDeclaration, typescript: typeof ts): boolean { - if (node.importClause?.isTypeOnly) return true - - const namedBindings = node.importClause?.namedBindings - if (node.importClause?.name || !namedBindings) return false - - return typescript.isNamedImports(namedBindings) && hasOnlyTypeOnlyElements(namedBindings.elements) -} - -function isTypeOnlyExport(node: ts.ExportDeclaration, typescript: typeof ts): boolean { - if (node.isTypeOnly) return true - - const exportClause = node.exportClause - if (!exportClause) return false - - return typescript.isNamedExports(exportClause) && hasOnlyTypeOnlyElements(exportClause.elements) -} - -function isWithinBoundary(filePath: string, boundaryDirectory?: string): boolean { - if (!boundaryDirectory) return true - return isSubpath(resolvePath(boundaryDirectory), resolvePath(filePath)) -} - -function isAllowedFile(filePath: string, options: FindAllImportedFilesOptions): boolean { - if (!options.allowedFiles) return true - - const resolvedPath = resolvePath(filePath) - return options.allowedFiles.has(resolvedPath) || Boolean(options.alwaysAllowedFiles?.has(resolvedPath)) -} - -function canIncludeImportedFile(filePath: string, options: FindAllImportedFilesOptions): boolean { - return !filePath.includes('node_modules') && !filePath.endsWith('.d.ts') && isAllowedFile(filePath, options) -} - -function canScanImportedFile(filePath: string, options: FindAllImportedFilesOptions): boolean { - const resolvedPath = resolvePath(filePath) - const isExplicitlyAllowed = - (options.allowedFiles?.has(resolvedPath) ?? false) || (options.alwaysAllowedFiles?.has(resolvedPath) ?? false) - - return ( - canIncludeImportedFile(filePath, options) && - (isWithinBoundary(filePath, options.boundaryDirectory) || isExplicitlyAllowed) - ) -} - -async function parseAndResolveImports( - filePath: string, - options: ParseAndResolveImportsOptions = {}, -): Promise { +async function parseAndResolveImports(filePath: string): Promise { try { - const resolvedFilePath = resolvePath(filePath) - const cachedImports = options.importCache?.get(resolvedFilePath) - if (cachedImports) { - return cachedImports - } - const ts = await loadTypeScript() const content = readFileSync(filePath).toString() const resolvedPaths: string[] = [] // Load TypeScript configuration once - const {compilerOptions} = await loadTsConfig(filePath, options.tsConfigCache) + const {compilerOptions} = await loadTsConfig(filePath) // Determine script kind based on file extension let scriptKind = ts.ScriptKind.JSX @@ -211,10 +119,6 @@ async function parseAndResolveImports( const visit = (node: ts.Node): void => { if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { - if (isTypeOnlyImport(node, ts)) { - return - } - importPaths.push(node.moduleSpecifier.text) } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { const firstArg = node.arguments[0] @@ -222,10 +126,6 @@ async function parseAndResolveImports( importPaths.push(firstArg.text) } } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { - if (isTypeOnlyExport(node, ts)) { - return - } - importPaths.push(node.moduleSpecifier.text) } @@ -260,7 +160,6 @@ async function parseAndResolveImports( } } - options.importCache?.set(resolvedFilePath, resolvedPaths) return resolvedPaths } catch (error) { // Re-throw AbortError as-is, wrap other errors @@ -271,45 +170,26 @@ async function parseAndResolveImports( } } -export async function findAllImportedFiles( - filePath: string, - options: FindAllImportedFilesOptions = {}, - visited = new Set(), -): Promise { +export async function findAllImportedFiles(filePath: string, visited = new Set()): Promise { if (visited.has(filePath)) { return [] } visited.add(filePath) - const resolvedPaths = (await parseAndResolveImports(filePath, options)).filter((resolvedPath) => - canIncludeImportedFile(resolvedPath, options), - ) + const resolvedPaths = await parseAndResolveImports(filePath) const allFiles = [...resolvedPaths] // Recursively find imports from the resolved files for (const resolvedPath of resolvedPaths) { - if (!canScanImportedFile(resolvedPath, options)) continue - // eslint-disable-next-line no-await-in-loop - const nestedImports = await findAllImportedFiles(resolvedPath, options, visited) + const nestedImports = await findAllImportedFiles(resolvedPath, visited) allFiles.push(...nestedImports) } return uniq(allFiles) } -export async function findExplicitTsConfigFiles( - fromFile: string, - _extensionDirectory: string, - options: {tsConfigCache?: TsConfigCache} = {}, -): Promise | undefined> { - const {configPath, fileNames, hasExplicitFiles} = await loadTsConfig(fromFile, options.tsConfigCache) - if (!configPath || !hasExplicitFiles) return - - return new Set((fileNames ?? []).map((fileName) => resolvePath(fileName))) -} - interface CreateTypeDefinitionOptions { fullPath: string typeFilePath: string diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 0312bc80480..594ed9e27cc 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -4,11 +4,9 @@ import { createTypeDefinition, createToolsTypeDefinition, findNearestTsConfigDir, - findExplicitTsConfigFiles, getGeneratedTypesHelperImportPath, IntentSchemaFileSchema, parseApiVersion, - type TsConfigCache, ToolsFileSchema, } from './type-generation.js' import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' @@ -20,7 +18,7 @@ import {formatContent} from '../../../utilities/file-formatter.js' import {uniq} from '@shopify/cli-kit/common/array' import {err, ok, Result} from '@shopify/cli-kit/node/result' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {joinPath, resolvePath} from '@shopify/cli-kit/node/path' +import {joinPath} from '@shopify/cli-kit/node/path' import {outputContent, outputToken, outputWarn} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' import {AbortError} from '@shopify/cli-kit/node/error' @@ -212,8 +210,6 @@ const uiExtensionSpec = createExtensionSpecification({ const fileToTargetsMap = new Map() const fileToToolsMap = new Map() const fileToIntentsMap = new Map>() - const importCache = new Map() - const tsConfigCache: TsConfigCache = new Map() // First pass: collect all entry point files and their targets for await (const extensionPoint of configuration.extension_points) { @@ -258,14 +254,7 @@ const uiExtensionSpec = createExtensionSpecification({ if (!exists) continue // Find all imported files recursively - const allowedFiles = await findExplicitTsConfigFiles(fullPath, extension.directory, {tsConfigCache}) - const importedFiles = await findAllImportedFiles(fullPath, { - boundaryDirectory: extension.directory, - allowedFiles, - alwaysAllowedFiles: new Set([resolvePath(fullPath)]), - importCache, - tsConfigCache, - }) + const importedFiles = await findAllImportedFiles(fullPath) // Associate imported files with this extension point's target for (const importedFile of importedFiles) { @@ -282,16 +271,7 @@ const uiExtensionSpec = createExtensionSpecification({ ) const shouldRenderExists = await fileExists(shouldRenderPath) if (shouldRenderExists) { - const shouldRenderAllowedFiles = await findExplicitTsConfigFiles(shouldRenderPath, extension.directory, { - tsConfigCache, - }) - const shouldRenderImports = await findAllImportedFiles(shouldRenderPath, { - boundaryDirectory: extension.directory, - allowedFiles: shouldRenderAllowedFiles, - alwaysAllowedFiles: new Set([resolvePath(shouldRenderPath)]), - importCache, - tsConfigCache, - }) + const shouldRenderImports = await findAllImportedFiles(shouldRenderPath) for (const importedFile of shouldRenderImports) { const currentTargets = fileToTargetsMap.get(importedFile) ?? [] currentTargets.push(getShouldRenderTarget(extensionPoint.target)) diff --git a/packages/app/src/cli/models/project/active-config.test.ts b/packages/app/src/cli/models/project/active-config.test.ts index 765187a7ac0..3f189f4e488 100644 --- a/packages/app/src/cli/models/project/active-config.test.ts +++ b/packages/app/src/cli/models/project/active-config.test.ts @@ -55,54 +55,6 @@ describe('selectActiveConfig', () => { }) }) - test('selects config by client ID when cached config is stale and prompts are skipped', async () => { - await inTemporaryDirectory(async (dir) => { - await writeFile(joinPath(dir, 'shopify.app.2.toml'), 'client_id = "matching-client-id"') - await writeFile(joinPath(dir, 'shopify.app.3.toml'), 'client_id = "other-client-id"') - const project = await Project.load(dir) - - vi.mocked(getCachedAppInfo).mockReturnValue({ - directory: dir, - configFile: 'shopify.app.toml', - }) - - const config = await selectActiveConfig(project, undefined, { - clientId: 'matching-client-id', - skipPrompts: true, - }) - - expect(basename(config.file.path)).toBe('shopify.app.2.toml') - expect(config.file.content.client_id).toBe('matching-client-id') - expect(config.source).toBe('flag') - }) - }) - - test('throws when no config matches the client ID', async () => { - await inTemporaryDirectory(async (dir) => { - await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "other-client-id"') - const project = await Project.load(dir) - - await expect(selectActiveConfig(project, undefined, {clientId: 'missing-client-id'})).rejects.toThrow( - "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", - ) - }) - }) - - test('throws when the config for the client ID is malformed', async () => { - await inTemporaryDirectory(async (dir) => { - await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "other-client-id"') - await writeFile( - joinPath(dir, 'shopify.app.malformed.toml'), - 'client_id = "requested-client-id"\nembedded = invalid', - ) - const project = await Project.load(dir) - - await expect(selectActiveConfig(project, undefined, {clientId: 'requested-client-id'})).rejects.toThrow( - "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", - ) - }) - }) - test('falls back to default shopify.app.toml when no flag or cache', async () => { await inTemporaryDirectory(async (dir) => { await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default-id"') diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts index cb17d27ef06..5f8ac1eccc2 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -43,9 +43,8 @@ export interface ActiveConfig { * * Resolution priority: * 1. userProvidedConfigName (from --config flag) - * 2. clientId (from --client-id flag) - * 3. Cached selection (from `app config use`) - * 4. Default (shopify.app.toml) + * 2. Cached selection (from `app config use`) + * 3. Default (shopify.app.toml) * * If the cached config file no longer exists on disk, prompts the user * to select a new one via `app config use`. @@ -57,10 +56,9 @@ export interface ActiveConfig { export async function selectActiveConfig( project: Project, userProvidedConfigName?: string, - options?: {clientId?: string; skipPrompts?: boolean}, + options?: {skipPrompts?: boolean}, ): Promise { let configName = userProvidedConfigName - const clientIdConfig = options?.clientId ? project.appConfigByClientId(options.clientId) : undefined // Check cache for previously selected config const cachedConfigName = getCachedAppInfo(project.directory)?.configFile @@ -78,31 +76,21 @@ export async function selectActiveConfig( configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false}) } - if (!configName && clientIdConfig) { - return buildActiveConfig(project, clientIdConfig, 'flag') - } - - if (!configName && options?.clientId) { - throw new AbortError( - "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", - ) - } - // Don't fall back to stale cached name — it points to a non-existent file - const resolvedConfigName = configName ?? (cacheIsStale ? undefined : cachedConfigName) + configName = configName ?? (cacheIsStale ? undefined : cachedConfigName) // Determine source after resolution so it reflects the actual selection path let source: ConfigSource if (userProvidedConfigName) { source = 'flag' - } else if (resolvedConfigName) { + } else if (configName) { source = 'cached' } else { source = 'default' } // Resolve the config file name and look it up in the project's pre-loaded files - const configurationFileName = getAppConfigurationFileName(resolvedConfigName) + const configurationFileName = getAppConfigurationFileName(configName) const file = project.appConfigByName(configurationFileName) if (!file) { throw new AbortError( diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 651b5ff7c24..e8c249fda76 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -111,6 +111,15 @@ export async function selectStorePrompt({ return currentStores.find((store) => store.shopId === id) } +export async function confirmConversionToTransferDisabledStorePrompt(): Promise { + return renderConfirmationPrompt({ + message: `Make this store transfer-disabled? For security, once you use a dev store to preview an app locally, the store can never be transferred to a merchant to use as a production store.`, + confirmationMessage: 'Yes, make this store transfer-disabled permanently', + cancellationMessage: 'No, select another store', + defaultValue: false, + }) +} + export async function appNamePrompt(currentName: string): Promise { return renderTextPrompt({ message: 'App name', diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index 2f9a4ba0e24..fca01a594c5 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -9,7 +9,6 @@ import {selectOrganizationPrompt} from '@shopify/organizations' import {beforeEach, describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('./use.js') vi.mock('../../../prompts/dev.js') @@ -17,12 +16,10 @@ vi.mock('@shopify/organizations') vi.mock('../../../prompts/config.js') vi.mock('../../local-storage') vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/system') vi.mock('../../dev/fetch.js') vi.mock('../../../utilities/developer-platform-client.js') vi.mock('../../../models/app/validation/multi-cli-warning.js') beforeEach(async () => { - vi.mocked(terminalSupportsPrompting).mockReturnValue(true) // Default mock for selectConfigName - tests that need a specific value can override vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') vi.mocked(fetchOrganizations).mockResolvedValue([ diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 1e70ad58b55..3088fb07db1 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -20,7 +20,6 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {setPathValue} from '@shopify/cli-kit/common/object' -import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('./use.js') vi.mock('../../../prompts/config.js') @@ -37,7 +36,6 @@ vi.mock('../../../models/app/loader.js', async () => { }) vi.mock('../../local-storage') vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/system') vi.mock('../../context/partner-account-info.js') vi.mock('../../context.js') vi.mock('../select-app.js') @@ -68,7 +66,6 @@ function buildDeveloperPlatformClient(extraFields: Partial { vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue(DEFAULT_REMOTE_CONFIGURATION) - vi.mocked(terminalSupportsPrompting).mockReturnValue(true) // Default mock for selectConfigName - tests that need a specific value can override vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') }) @@ -118,30 +115,6 @@ describe('link', () => { }) }) - test('throws in non-TTY when the remote app would require prompting', async () => { - await inTemporaryDirectory(async (tmp) => { - // Given - vi.mocked(terminalSupportsPrompting).mockReturnValue(false) - const developerPlatformClient = buildDeveloperPlatformClient() - const options: LinkOptions = { - directory: tmp, - developerPlatformClient, - } - await mockLoadOpaqueAppWithApp(tmp) - - // When - const result = link(options) - - // Then - await expect(result).rejects.toMatchObject({ - formattedMessage: expect.arrayContaining([{command: 'app config link'}]), - tryMessage: expect.arrayContaining([{command: expect.stringContaining('--client-id')}]), - }) - expect(fetchOrCreateOrganizationApp).not.toHaveBeenCalled() - expect(selectConfigName).not.toHaveBeenCalled() - }) - }) - test('does not ask for a name when a file name is provided as a flag', async () => { await inTemporaryDirectory(async (tmp) => { // Given @@ -943,33 +916,6 @@ describe('link', () => { }) }) - test('throws in non-TTY when the config file name would require prompting', async () => { - await inTemporaryDirectory(async (tmp) => { - // Given - vi.mocked(terminalSupportsPrompting).mockReturnValue(false) - const developerPlatformClient = buildDeveloperPlatformClient() - const remoteApp = mockRemoteApp({developerPlatformClient, apiKey: 'new-api-key'}) - const options: LinkOptions = { - directory: tmp, - apiKey: remoteApp.apiKey, - developerPlatformClient, - } - writeFileSync(joinPath(tmp, 'shopify.app.toml'), 'client_id = "existing-api-key"') - await mockLoadOpaqueAppWithApp(tmp) - vi.mocked(appFromIdentifiers).mockResolvedValue(remoteApp) - - // When - const result = link(options) - - // Then - await expect(result).rejects.toMatchObject({ - formattedMessage: expect.arrayContaining([{command: 'app config link'}]), - tryMessage: expect.arrayContaining([{command: expect.stringContaining('--file-name')}]), - }) - expect(selectConfigName).not.toHaveBeenCalled() - }) - }) - test('uses scopes on platform if defined', async () => { await inTemporaryDirectory(async (tmp) => { // Given diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index a8029d1b577..5ef02ac9dda 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -30,7 +30,6 @@ import {fileExists} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' -import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' export interface LinkOptions { directory: string @@ -95,20 +94,6 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t return {remoteApp, configFileName, configuration: mergedAppConfiguration} } -function abortIfLinkPromptCannotRun(missingFlags: string[]) { - if (terminalSupportsPrompting() || missingFlags.length === 0) return - - const flags = missingFlags.map((flag) => `--${flag}`).join(' ') - throw new AbortError( - [{command: 'app config link'}, 'requires additional flags in non-interactive terminal environments.'], - [ - 'Run', - {command: `shopify app config link ${flags}`}, - 'with the required values, or run the command in an interactive terminal.', - ], - ) -} - /** * Choose or create an app on the platform to link to. * @@ -140,7 +125,6 @@ async function selectOrCreateRemoteAppToLinkTo(options: LinkOptions): Promise<{ } } - if (!options.organizationId) abortIfLinkPromptCannotRun(['client-id']) const remoteApp = await fetchOrCreateOrganizationApp({ ...creationOptions, directory: appDirectory, @@ -343,7 +327,6 @@ async function loadConfigurationFileName( // If no TOML files exist at all, use the default filename without prompting if (isEmpty(existingTomls)) return 'shopify.app.toml' - abortIfLinkPromptCannotRun(['file-name']) return selectConfigName(configDirectory, remoteApp.title) } diff --git a/packages/app/src/cli/services/deploy.ts b/packages/app/src/cli/services/deploy.ts index 758fdb83a8f..83f9fd5734c 100644 --- a/packages/app/src/cli/services/deploy.ts +++ b/packages/app/src/cli/services/deploy.ts @@ -156,8 +156,6 @@ export async function deploy(options: DeployOptions) { allowDeletes, }) - const appModuleUuids = {...identifiers.extensions, ...identifiers.extensionsNonUuidManaged} - const release = !noRelease const apiKey = remoteApp.apiKey @@ -180,13 +178,15 @@ export async function deploy(options: DeployOptions) { ) await mkdir(dirname(candidateBundlePath)) - const appManifest = await app.manifest(appManifestUuids(app, appModuleUuids)) + const appManifest = await app.manifest(identifiers) const bundlePath = await bundleAndBuildExtensions({ app, appManifest, bundlePath: candidateBundlePath, + identifiers, skipBuild: options.skipBuild, + isDevDashboardApp: true, }) let uploadTaskTitle @@ -209,7 +209,7 @@ export async function deploy(options: DeployOptions) { task: async () => { const appModules = await Promise.all( app.allExtensions.flatMap((ext) => - ext.bundleConfig({appModuleUuids, developerPlatformClient, apiKey, appConfiguration: app.configuration}), + ext.bundleConfig({identifiers, developerPlatformClient, apiKey, appConfiguration: app.configuration}), ), ) @@ -346,11 +346,3 @@ async function outputCompletionMessage({ ], }) } - -function appManifestUuids(app: AppLinkedInterface, appModuleUuids: {[localIdentifier: string]: string}) { - return Object.fromEntries( - app.allExtensions - .filter((extension) => extension.isUUIDStrategyExtension) - .map((extension) => [extension.localIdentifier, appModuleUuids[extension.localIdentifier]!]), - ) -} diff --git a/packages/app/src/cli/services/deploy/bundle.test.ts b/packages/app/src/cli/services/deploy/bundle.test.ts index e1253552561..941194362d8 100644 --- a/packages/app/src/cli/services/deploy/bundle.test.ts +++ b/packages/app/src/cli/services/deploy/bundle.test.ts @@ -33,14 +33,26 @@ describe('bundleAndBuildExtensions', () => { themeExtension.buildForBundle = extensionBundleMock app = testApp({allExtensions: [uiExtension, themeExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const extensions: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + extensions[extension.localIdentifier] = extension.localIdentifier + } + const identifiers = { + app: 'app-id', + extensions, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: false, + isDevDashboardApp: false, }) // Then @@ -63,14 +75,26 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBundleMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const extensions: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + extensions[extension.localIdentifier] = extension.localIdentifier + } + const identifiers = { + app: 'app-id', + extensions, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: false, + isDevDashboardApp: false, }) // Then @@ -90,20 +114,33 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const extensions: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + extensions[extension.localIdentifier] = extension.localIdentifier + } + const identifiers = { + app: 'app-id', + extensions, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: true, + isDevDashboardApp: false, }) // Then expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + functionExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() }) @@ -122,14 +159,22 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: true, + isDevDashboardApp: false, }) // Then @@ -137,6 +182,7 @@ describe('bundleAndBuildExtensions', () => { expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + functionExtension.localIdentifier, ) }) }) @@ -154,14 +200,22 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const identifiers = { + app: 'app-id', + extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: false, + isDevDashboardApp: false, }) // Then @@ -184,20 +238,29 @@ describe('bundleAndBuildExtensions', () => { const app = testApp({allExtensions: [themeExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const identifiers = { + app: 'app-id', + extensions: {[themeExtension.localIdentifier]: themeExtension.localIdentifier}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: true, + isDevDashboardApp: false, }) // Then expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + themeExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() }) @@ -235,27 +298,42 @@ describe('bundleAndBuildExtensions', () => { directory: tmpDir, }) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const extensions: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + extensions[extension.localIdentifier] = extension.localIdentifier + } + const identifiers = { + app: 'app-id', + extensions, + extensionIds: {}, + extensionsNonUuidManaged: {}, + } + appManifest = await app.manifest(identifiers) // When await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: true, + isDevDashboardApp: false, }) expect(functionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + functionExtension.localIdentifier, ) expect(themeBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + themeExtension.localIdentifier, ) expect(uiBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), + uiExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() @@ -271,14 +349,22 @@ describe('bundleAndBuildExtensions', () => { const configExtension = await testAppConfigExtensions() const app = testApp({allExtensions: [configExtension], directory: tmpDir}) - appManifest = await app.manifest(appModuleUuidsFor(app)) + const identifiers = { + app: 'app-id', + extensions: {}, + extensionIds: {}, + extensionsNonUuidManaged: {[configExtension.localIdentifier]: configExtension.localIdentifier}, + } + appManifest = await app.manifest(identifiers) // When const result = await bundleAndBuildExtensions({ app, appManifest, + identifiers, bundlePath, skipBuild: false, + isDevDashboardApp: false, }) // Then @@ -288,9 +374,3 @@ describe('bundleAndBuildExtensions', () => { }) }) }) - -function appModuleUuidsFor(app: AppInterface) { - return Object.fromEntries( - app.allExtensions.map((extension) => [extension.localIdentifier, extension.localIdentifier]), - ) -} diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index e0a5bd83bcf..1f8e482d952 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -1,4 +1,5 @@ import {AppInterface, AppManifest} from '../../models/app/app.js' +import {Identifiers} from '../../models/app/identifiers.js' import {installJavy} from '../function/build.js' import {compressBundle, writeManifestToBundle} from '../bundle.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' @@ -11,7 +12,9 @@ interface BundleOptions { app: AppInterface appManifest: AppManifest bundlePath: string + identifiers?: Identifiers skipBuild: boolean + isDevDashboardApp: boolean } /** @@ -36,9 +39,16 @@ export async function bundleAndBuildExtensions(options: BundleOptions): Promise< const extensionBuildProcesses = options.app.allExtensions.map((extension) => ({ prefix: extension.localIdentifier, action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { + // This outputId is the UID for AppManagement, and UUID for Partners + // Comes from the matching logic in `ensureDeployContext` + const outputId = options.isDevDashboardApp + ? undefined + : options.identifiers?.extensions[extension.localIdentifier] + await extension.buildForBundle( {stderr, stdout, signal, app: options.app, environment: 'production', skipBuild: options.skipBuild}, bundleDirectory, + outputId, ) }, })) diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 24e0b135bb0..0dfcb3b09e1 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -57,11 +57,9 @@ export interface DevOptions { tunnel: TunnelMode theme?: string themeExtensionPort?: number - storePassword?: string notify?: string graphiqlPort?: number graphiqlKey?: string - installMkcert?: boolean } export async function dev(commandOptions: DevOptions) { @@ -143,7 +141,6 @@ async function prepareForDev(commandOptions: DevOptions): Promise { tunnel, tunnelClient, remoteApp.configuration, - commandOptions.installMkcert, ) app.webs = webs @@ -253,7 +250,6 @@ async function setupNetworkingOptions( tunnelOptions: TunnelMode, tunnelClient?: TunnelClient, remoteAppConfig?: AppConfigurationUsedByCli, - installMkcert?: boolean, ) { const {backendConfig, frontendConfig} = frontAndBackendConfig(webs) @@ -291,7 +287,6 @@ async function setupNetworkingOptions( if (tunnelOptions.mode === 'use-localhost') { const {keyContent, certContent, certPath} = await generateCertificate({ appDirectory, - forceInstall: installMkcert, }) reverseProxyCert = { diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts index c89147dbb3e..207dc94a4b0 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts @@ -74,20 +74,28 @@ const testAppConfiguration: CurrentAppConfiguration = { embedded: true, } -/** Waits for the watcher to emit a given event. */ +/** + * Waits for the watcher to emit a given event by polling the emit spy. + * This replaces fragile fixed-timeout waits (setTimeout(10)) that cause flaky tests when the async + * event processing chain takes longer than expected. + */ type EmitSpy = MockInstance<(eventName: string | symbol, ...args: unknown[]) => boolean> async function waitForWatcherEmit(emitSpy: EmitSpy, event: string, timeoutMs = 3000): Promise { - await vi.waitFor( - () => { - const emittedEvents = emitSpy.mock.calls.map(([eventName]) => String(eventName)) - expect( - emittedEvents, - `Expected watcher to emit "${event}". Emitted events: ${emittedEvents.join(', ')}`, - ).toContain(event) - }, - {timeout: timeoutMs, interval: 10}, - ) + await new Promise((resolve, reject) => { + const startTime = Date.now() + const poll = () => { + const emitted = emitSpy.mock.calls.some((call) => call[0] === event) + if (emitted) { + resolve() + } else if (Date.now() - startTime < timeoutMs) { + setTimeout(poll, 10) + } else { + reject(new Error(`Timeout waiting for watcher to emit "${event}" event`)) + } + } + poll() + }) } /** Waits until successful change handling finishes (`emit('all', ...)`). */ diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index 520f2a6f551..20162aead60 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -22,17 +22,53 @@ vi.mock('@shopify/cli-kit/node/import-extractor', () => ({ extractJSImports: vi.fn(() => []), })) +// Mock fs module for fileExistsSync, mkdir, and writeFile +vi.mock('@shopify/cli-kit/node/fs', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/fs') + return { + ...actual, + fileExistsSync: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + } +}) + +// Mock resolvePath to handle path resolution in tests +vi.mock('@shopify/cli-kit/node/path', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/path') + return { + ...actual, + resolvePath: vi.fn((path: string) => { + // For test purposes, convert relative paths to absolute paths + if (path.startsWith('../')) { + // Simple resolution for test paths + if (path === '../../shared/constants') return '/test/shared/constants.rs' + if (path === '../../../shared/utils') return '/test/shared/utils.rs' + if (path === '../constants') return '/test/constants.rs' + } + return path + }), + } +}) + // Helper to mock watchedFiles for extensions function mockExtensionWatchedFiles(extension: any, files: string[] = []) { vi.spyOn(extension, 'watchedFiles').mockReturnValue(files) } +const extension1 = await testUIExtension({type: 'ui_extension', handle: 'h1', directory: '/extensions/ui_extension_1'}) +const extension1B = await testUIExtension({type: 'ui_extension', handle: 'h2', directory: '/extensions/ui_extension_1'}) +const extension2 = await testUIExtension({type: 'ui_extension', directory: '/extensions/ui_extension_2'}) +const functionExtension = await testFunctionExtension({dir: '/extensions/my-function'}) +const posExtension = await testAppConfigExtensions() +const appAccessExtension = await testAppAccessConfigExtension() + /** * Test case for the file-watcher * Each test case is an object containing the following elements: * - A name for the test case * - The file system event to be triggered - * - The path of the file that triggered the event (relative to app root) + * - The path of the file that triggered the event */ interface TestCaseSingleEvent { name: string @@ -45,6 +81,13 @@ interface TestCaseSingleEvent { /** * Test case for the file-watcher + * There are cases where multiple events are triggered in a short period of time. + * This test cases are used to test those scenarios. + * + * Each test case is an object containing the following elements: + * - A name for the test case + * - The file system events to be triggered + * - The expected event to be received by the onChange callback */ interface TestCaseMultiEvent { name: string @@ -56,11 +99,11 @@ const singleEventTestCases: TestCaseSingleEvent[] = [ { name: 'change in file', fileSystemEvent: 'change', - path: 'extensions/ui_extension_1/index.js', + path: '/extensions/ui_extension_1/index.js', expectedEvent: { type: 'file_updated', - path: 'extensions/ui_extension_1/index.js', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1/index.js', + extensionPath: '/extensions/ui_extension_1', extensionHandle: 'h1', }, expectedEventCount: 2, @@ -69,11 +112,11 @@ const singleEventTestCases: TestCaseSingleEvent[] = [ { name: 'change in toml', fileSystemEvent: 'change', - path: 'extensions/ui_extension_1/shopify.ui.extension.toml', + path: '/extensions/ui_extension_1/shopify.ui.extension.toml', expectedEvent: { type: 'extensions_config_updated', - path: 'extensions/ui_extension_1/shopify.ui.extension.toml', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1/shopify.ui.extension.toml', + extensionPath: '/extensions/ui_extension_1', extensionHandle: 'h1', }, expectedEventCount: 2, @@ -82,21 +125,21 @@ const singleEventTestCases: TestCaseSingleEvent[] = [ { name: 'change in app config', fileSystemEvent: 'change', - path: 'shopify.app.toml', + path: '/shopify.app.toml', expectedEvent: { type: 'extensions_config_updated', - path: 'shopify.app.toml', - extensionPath: '', + path: '/shopify.app.toml', + extensionPath: '/', }, }, { name: 'add a new file', fileSystemEvent: 'add', - path: 'extensions/ui_extension_1/new-file.js', + path: '/extensions/ui_extension_1/new-file.js', expectedEvent: { type: 'file_created', - path: 'extensions/ui_extension_1/new-file.js', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1/new-file.js', + extensionPath: '/extensions/ui_extension_1', extensionHandle: 'h1', }, expectedEventCount: 2, @@ -105,37 +148,37 @@ const singleEventTestCases: TestCaseSingleEvent[] = [ { name: 'delete a file', fileSystemEvent: 'unlink', - path: 'extensions/ui_extension_1/index.js', + path: '/extensions/ui_extension_1/index.js', expectedEvent: { type: 'file_deleted', - path: 'extensions/ui_extension_1/index.js', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1/index.js', + extensionPath: '/extensions/ui_extension_1', }, }, { name: 'add a new extension', fileSystemEvent: 'add', - path: 'extensions/ui_extension_3/shopify.extension.toml', + path: '/extensions/ui_extension_3/shopify.extension.toml', expectedEvent: { type: 'extension_folder_created', - path: 'extensions/ui_extension_3', + path: '/extensions/ui_extension_3', extensionPath: 'unknown', }, }, { name: 'delete an extension', fileSystemEvent: 'unlink', - path: 'extensions/ui_extension_1/shopify.extension.toml', + path: '/extensions/ui_extension_1/shopify.extension.toml', expectedEvent: { type: 'extension_folder_deleted', - path: 'extensions/ui_extension_1', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1', + extensionPath: '/extensions/ui_extension_1', }, }, { name: 'change in function extension is ignored if not in watch list', fileSystemEvent: 'change', - path: 'extensions/my-function/src/cargo.lock', + path: '/extensions/my-function/src/cargo.lock', expectedEvent: undefined, }, ] @@ -144,52 +187,64 @@ const multiEventTestCases: TestCaseMultiEvent[] = [ { name: 'Add a new folder with files', fileSystemEvents: [ - {event: 'addDir', path: 'extensions/ui_extension_3'}, - {event: 'add', path: 'extensions/ui_extension_3/shopify.extension.toml'}, - {event: 'add', path: 'extensions/ui_extension_3/index.js'}, - {event: 'add', path: 'extensions/ui_extension_3/new-file.js'}, - {event: 'change', path: 'extensions/ui_extension_3/index.js'}, + // When adding a folder, the events are emitted in order (first the root, then all files) + {event: 'addDir', path: '/extensions/ui_extension_3'}, + {event: 'add', path: '/extensions/ui_extension_3/shopify.extension.toml'}, + {event: 'add', path: '/extensions/ui_extension_3/index.js'}, + {event: 'add', path: '/extensions/ui_extension_3/new-file.js'}, + {event: 'change', path: '/extensions/ui_extension_3/index.js'}, ], expectedEvent: { type: 'extension_folder_created', - path: 'extensions/ui_extension_3', + path: '/extensions/ui_extension_3', extensionPath: 'unknown', }, }, { name: 'Delete a folder with files', fileSystemEvents: [ - {event: 'unlink', path: 'extensions/ui_extension_1/index.js'}, - {event: 'unlink', path: 'extensions/ui_extension_1/new-file.js'}, - {event: 'unlink', path: 'extensions/ui_extension_1/shopify.extension.toml'}, - {event: 'unlinkDir', path: 'extensions/ui_extension_1/index.js'}, - {event: 'unlinkDir', path: 'extensions/ui_extension_1'}, + // When deleting a folder, the events are emitted in reverse order (first the files, then the root) + {event: 'unlink', path: '/extensions/ui_extension_1/index.js'}, + {event: 'unlink', path: '/extensions/ui_extension_1/new-file.js'}, + {event: 'unlink', path: '/extensions/ui_extension_1/shopify.extension.toml'}, + {event: 'unlinkDir', path: '/extensions/ui_extension_1/index.js'}, + {event: 'unlinkDir', path: '/extensions/ui_extension_1'}, ], expectedEvent: { type: 'extension_folder_deleted', - path: 'extensions/ui_extension_1', - extensionPath: 'extensions/ui_extension_1', + path: '/extensions/ui_extension_1', + extensionPath: '/extensions/ui_extension_1', }, }, ] const outputOptions: OutputContextOptions = {stdout: process.stdout, stderr: process.stderr, signal: new AbortSignal()} +const defaultApp = testAppLinked({ + allExtensions: [extension1, extension1B, extension2, posExtension, appAccessExtension, functionExtension], + directory: '/', + configPath: '/shopify.app.toml', + configuration: { + ...DEFAULT_CONFIG, + extension_directories: ['/extensions'], + } as any, +}) describe('file-watcher events', () => { test('The file watcher is started with the correct paths and options', async () => { // Given await inTemporaryDirectory(async (dir) => { - const ext1 = await testUIExtension({type: 'ui_extension', directory: joinPath(dir, 'extensions/ext1')}) - const ext2 = await testUIExtension({type: 'ui_extension', directory: joinPath(dir, 'extensions/ext2')}) + const ext1 = await testUIExtension({type: 'ui_extension', directory: joinPath(dir, '/extensions/ext1')}) + const ext2 = await testUIExtension({type: 'ui_extension', directory: joinPath(dir, '/extensions/ext2')}) const posExtension = await testAppConfigExtensions(false, dir) + // Mock watchedFiles to return empty array for these test extensions mockExtensionWatchedFiles(ext1) mockExtensionWatchedFiles(ext2) mockExtensionWatchedFiles(posExtension) const app = testAppLinked({ allExtensions: [ext1, ext2, posExtension], directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), + configPath: joinPath(dir, '/shopify.app.toml'), configuration: { client_id: 'test-client-id', name: 'my-app', @@ -199,12 +254,13 @@ describe('file-watcher events', () => { }, }) - await mkdir(joinPath(dir, 'extensions/ext1')) - await writeFile(joinPath(dir, 'extensions/ext1/.gitignore'), '#comment\na_folder\na_file.txt\n**/nested/**') + // Add a custom gitignore file to the extension + await mkdir(joinPath(dir, '/extensions/ext1')) + await writeFile(joinPath(dir, '/extensions/ext1/.gitignore'), '#comment\na_folder\na_file.txt\n**/nested/**') const watchSpy = vi.spyOn(chokidar, 'watch').mockImplementation(() => { return { - on: (_: string, listener: any) => listener('change', joinPath(dir, 'shopify.app.toml')), + on: (_: string, listener: any) => listener('change', joinPath(dir, '/shopify.app.toml')), close: () => Promise.resolve(), } as any }) @@ -216,7 +272,7 @@ describe('file-watcher events', () => { await fileWatcher.start() // Then - expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, 'shopify.app.toml'), joinPath(dir, 'extensions')], { + expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, persistent: true, @@ -227,135 +283,115 @@ describe('file-watcher events', () => { test.each(singleEventTestCases)( 'The event $name returns the expected WatcherEvent', async ({fileSystemEvent, path, expectedEvent, expectedEventCount, expectedHandles}) => { - await inTemporaryDirectory(async (dir) => { - // Given - const extension1 = await testUIExtension({ - type: 'ui_extension', - handle: 'h1', - directory: joinPath(dir, 'extensions/ui_extension_1'), - }) - const extension1B = await testUIExtension({ - type: 'ui_extension', - handle: 'h2', - directory: joinPath(dir, 'extensions/ui_extension_1'), - }) - const extension2 = await testUIExtension({ - type: 'ui_extension', - directory: joinPath(dir, 'extensions/ui_extension_2'), - }) - const functionExtension = await testFunctionExtension({dir: joinPath(dir, 'extensions/my-function')}) - const posExtension = await testAppConfigExtensions(false, dir) - const appAccessExtension = await testAppAccessConfigExtension(false, dir) - - mockExtensionWatchedFiles(extension1, [ - joinPath(dir, 'extensions/ui_extension_1/index.js'), - joinPath(dir, 'extensions/ui_extension_1/shopify.ui.extension.toml'), - joinPath(dir, 'extensions/ui_extension_1/shopify.extension.toml'), - joinPath(dir, 'extensions/ui_extension_1/new-file.js'), - ]) - mockExtensionWatchedFiles(extension1B, [ - joinPath(dir, 'extensions/ui_extension_1/index.js'), - joinPath(dir, 'extensions/ui_extension_1/shopify.ui.extension.toml'), - joinPath(dir, 'extensions/ui_extension_1/shopify.extension.toml'), - joinPath(dir, 'extensions/ui_extension_1/new-file.js'), - ]) - mockExtensionWatchedFiles(extension2, [ - joinPath(dir, 'extensions/ui_extension_2/index.js'), - joinPath(dir, 'extensions/ui_extension_2/shopify.extension.toml'), - ]) - mockExtensionWatchedFiles(functionExtension, [joinPath(dir, 'extensions/my-function/src/index.js')]) - mockExtensionWatchedFiles(posExtension, []) - mockExtensionWatchedFiles(appAccessExtension, []) - - const testApp = testAppLinked({ - allExtensions: [extension1, extension1B, extension2, posExtension, appAccessExtension, functionExtension], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - configuration: { - ...DEFAULT_CONFIG, - extension_directories: ['extensions'], - } as any, - }) - - let eventHandler: any - const mockWatcher = { - on: vi.fn((event: string, listener: any) => { - if (event === 'all') { - eventHandler = listener - } - return mockWatcher - }), - close: vi.fn(() => Promise.resolve()), - } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) - - const fileWatcher = new FileWatcher(testApp, outputOptions, 50) - const onChange = vi.fn() - fileWatcher.onChange(onChange) + // Given + let eventHandler: any + + // Mock watchedFiles for the extensions + mockExtensionWatchedFiles(extension1, [ + '/extensions/ui_extension_1/index.js', + '/extensions/ui_extension_1/shopify.ui.extension.toml', + '/extensions/ui_extension_1/shopify.extension.toml', + '/extensions/ui_extension_1/new-file.js', + ]) + mockExtensionWatchedFiles(extension1B, [ + '/extensions/ui_extension_1/index.js', + '/extensions/ui_extension_1/shopify.ui.extension.toml', + '/extensions/ui_extension_1/shopify.extension.toml', + '/extensions/ui_extension_1/new-file.js', + ]) + mockExtensionWatchedFiles(extension2, [ + '/extensions/ui_extension_2/index.js', + '/extensions/ui_extension_2/shopify.extension.toml', + ]) + mockExtensionWatchedFiles(functionExtension, ['/extensions/my-function/src/index.js']) + mockExtensionWatchedFiles(posExtension, []) + mockExtensionWatchedFiles(appAccessExtension, []) + + const testApp = { + ...defaultApp, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } - await fileWatcher.start() - await flushPromises() - - const fullPath = joinPath(dir, path) - const expectedEventPath = expectedEvent?.path ? joinPath(dir, expectedEvent.path) : fullPath - - if (eventHandler) { - if ( - (fileSystemEvent === 'unlink' && !path.endsWith('.toml')) || - (fileSystemEvent === 'add' && path.endsWith('.toml') && path.includes('ui_extension_3')) - ) { - onChange([ - { - type: expectedEvent!.type, - path: normalizePath(expectedEventPath), - extensionPath: - expectedEvent!.extensionPath === 'unknown' - ? 'unknown' - : normalizePath(joinPath(dir, expectedEvent!.extensionPath)), - startTime: [Date.now(), 0] as [number, number], - }, - ]) - } else { - await eventHandler(fileSystemEvent, fullPath, undefined) + const mockWatcher = { + on: vi.fn((event: string, listener: any) => { + if (event === 'all') { + eventHandler = listener } - } + return mockWatcher + }), + close: vi.fn(() => Promise.resolve()), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) - if (expectedEvent) { - await vi.waitFor( - () => { - expect(onChange).toHaveBeenCalled() - const calls = onChange.mock.calls - const actualEvents = calls.find((call) => call[0].length > 0)?.[0] - - if (!actualEvents) { - throw new Error('Expected onChange to be called with events, but all calls had empty arrays') - } - - const eventCount = expectedEventCount ?? 1 - expect(actualEvents).toHaveLength(eventCount) - const actualEvent = actualEvents[0] - - expect(actualEvent.type).toBe(expectedEvent.type) - expect(actualEvent.path).toBe(normalizePath(expectedEventPath)) - const expectedExtensionPath = - expectedEvent.extensionPath === 'unknown' ? 'unknown' : joinPath(dir, expectedEvent.extensionPath) - expect(actualEvent.extensionPath).toBe(normalizePath(expectedExtensionPath)) - expect(Array.isArray(actualEvent.startTime)).toBe(true) - - if (expectedHandles) { - const actualHandles = actualEvents.map((event: WatcherEvent) => event.extensionHandle).sort() - expect(actualHandles).toEqual(expectedHandles.sort()) - } else if (expectedEvent.extensionHandle) { - expect(actualEvent.extensionHandle).toBe(expectedEvent.extensionHandle) - } + // Mock fileExistsSync to return false for lock files (needed for new extension creation) + vi.mocked(fileExistsSync).mockReturnValue(false) + + // Create file watcher with a short debounce time + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) + const onChange = vi.fn() + fileWatcher.onChange(onChange) + + await fileWatcher.start() + await flushPromises() + + if (eventHandler) { + // For unlink or add, that include timeouts, directly call onChange with the expected event. + if ( + (fileSystemEvent === 'unlink' && !path.endsWith('.toml')) || + (fileSystemEvent === 'add' && path.endsWith('.toml') && path.includes('ui_extension_3')) + ) { + onChange([ + { + type: expectedEvent!.type, + path: expectedEvent!.path, + extensionPath: expectedEvent!.extensionPath, + startTime: [Date.now(), 0] as [number, number], }, - {timeout: 1000, interval: 50}, - ) + ]) } else { - const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0) - expect(hasNonEmptyCall).toBe(false) + // Normal event handling + await eventHandler(fileSystemEvent, path, undefined) } - }) + } + + if (expectedEvent) { + await vi.waitFor( + () => { + expect(onChange).toHaveBeenCalled() + const calls = onChange.mock.calls + const actualEvents = calls.find((call) => call[0].length > 0)?.[0] + + if (!actualEvents) { + throw new Error('Expected onChange to be called with events, but all calls had empty arrays') + } + + const eventCount = expectedEventCount ?? 1 + expect(actualEvents).toHaveLength(eventCount) + const actualEvent = actualEvents[0] + + expect(actualEvent.type).toBe(expectedEvent.type) + expect(actualEvent.path).toBe(normalizePath(expectedEvent.path)) + expect(actualEvent.extensionPath).toBe(normalizePath(expectedEvent.extensionPath)) + expect(Array.isArray(actualEvent.startTime)).toBe(true) + expect(actualEvent.startTime).toHaveLength(2) + + // Verify extensionHandle is set correctly on file-level events + if (expectedHandles) { + const actualHandles = actualEvents.map((event: WatcherEvent) => event.extensionHandle).sort() + expect(actualHandles).toEqual(expectedHandles.sort()) + } else if (expectedEvent.extensionHandle) { + expect(actualEvent.extensionHandle).toBe(expectedEvent.extensionHandle) + } + }, + {timeout: 1000, interval: 50}, + ) + } else { + // For events that should not trigger + const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0) + expect(hasNonEmptyCall).toBe(false) + } }, ) @@ -363,50 +399,49 @@ describe('file-watcher events', () => { 'The event $name returns the expected WatcherEvent', async ({name, fileSystemEvents, expectedEvent}) => { await inTemporaryDirectory(async (dir) => { - const app = testAppLinked({ - allExtensions: [], + const testApp = { + ...defaultApp, directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - configuration: { - ...DEFAULT_CONFIG, - extension_directories: ['extensions'], - } as any, - }) + realDirectory: dir, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } + + // Mock fileExistsSync to return false (handles lock files and .gitignore) + vi.mocked(fileExistsSync).mockReturnValue(false) const onChange = vi.fn() const mockWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as any) - const fileWatcher = new FileWatcher(app, outputOptions, 50) + // Create file watcher + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) fileWatcher.onChange(onChange) await fileWatcher.start() + // For both multi-event cases, we need to manually trigger the expected event if (expectedEvent) { - const fullPath = joinPath(dir, expectedEvent.path) - const expectedExtensionPath = - expectedEvent.extensionPath === 'unknown' ? 'unknown' : joinPath(dir, expectedEvent.extensionPath) onChange([ { type: expectedEvent.type, - path: fullPath, - extensionPath: expectedExtensionPath, + path: expectedEvent.path, + extensionPath: expectedEvent.extensionPath, startTime: [Date.now(), 0] as [number, number], }, ]) } + // Verify results if (expectedEvent) { - const fullPath = joinPath(dir, expectedEvent.path) - const expectedExtensionPath = - expectedEvent.extensionPath === 'unknown' ? 'unknown' : joinPath(dir, expectedEvent.extensionPath) expect(onChange).toHaveBeenCalledWith([ expect.objectContaining({ type: expectedEvent.type, - path: normalizePath(fullPath), - extensionPath: normalizePath(expectedExtensionPath), + path: expectedEvent.path, + extensionPath: expectedEvent.extensionPath, }), ]) } else { @@ -420,232 +455,265 @@ describe('file-watcher events', () => { describe('imported file handling', () => { test('detects changes in imported files outside extension directories', async () => { - await inTemporaryDirectory(async (dir) => { - const mockedExtractImportPaths = extractImportPathsRecursively as any + const mockedExtractImportPaths = extractImportPathsRecursively as any - const extensionDir = joinPath(dir, 'extensions/my-function') - const mainFile = joinPath(extensionDir, 'src/main.rs') - const constantsFile = joinPath(dir, 'shared/constants.rs') + // Simple paths for testing + const extensionDir = '/test/extensions/my-function' + const mainFile = joinPath(extensionDir, 'src', 'main.rs') + const constantsFile = '/test/shared/constants.rs' - mockedExtractImportPaths.mockImplementation((filePath: string) => { - if (filePath === mainFile) { - return ['../../shared/constants'] - } - return [] - }) + // Mock import extraction to return relative paths + mockedExtractImportPaths.mockImplementation((filePath: string) => { + if (filePath === mainFile) { + return ['../../shared/constants'] + } + return [] + }) - const testFunction = await testFunctionExtension({ - dir: extensionDir, - }) - testFunction.entrySourceFilePath = mainFile - vi.spyOn(testFunction, 'watchedFiles').mockReturnValue([mainFile, constantsFile]) + // Create test extension + const testFunction = await testFunctionExtension({ + dir: extensionDir, + }) + testFunction.entrySourceFilePath = mainFile - const app = testAppLinked({ - allExtensions: [testFunction], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) - - let watchedPaths: string[] = [] - vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { - watchedPaths = paths as string[] - return { - on: vi.fn().mockReturnThis(), - close: vi.fn().mockResolvedValue(undefined), - } as any - }) - - const fileWatcher = new FileWatcher(app, outputOptions) - await fileWatcher.start() + // Mock the watchedFiles method to return the expected files + vi.spyOn(testFunction, 'watchedFiles').mockReturnValue([mainFile, constantsFile]) - expect(watchedPaths).toContain(normalizePath(constantsFile)) - mockedExtractImportPaths.mockReset() + const app = testAppLinked({ + allExtensions: [testFunction], + directory: '/test', + }) + + // Mock chokidar - we need to check the paths passed to watch + let watchedPaths: string[] = [] + vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { + watchedPaths = paths as string[] + return { + on: vi.fn().mockReturnThis(), + close: vi.fn().mockResolvedValue(undefined), + } as any }) + + const fileWatcher = new FileWatcher(app, outputOptions) + await fileWatcher.start() + + // Check that imported file was included in the initial watch paths + expect(watchedPaths).toContain(constantsFile) + + // Clean up + mockedExtractImportPaths.mockReset() }) test('handles imported files that are imported by multiple extensions', async () => { - await inTemporaryDirectory(async (dir) => { - const mockedExtractImportPaths = extractImportPathsRecursively as any + const mockedExtractImportPaths = extractImportPathsRecursively as any + + // Simple paths for testing + const extension1Dir = '/test/extensions/function1' + const extension2Dir = '/test/extensions/function2' + const mainFile1 = joinPath(extension1Dir, 'src', 'main.rs') + const mainFile2 = joinPath(extension2Dir, 'src', 'main.rs') + const sharedFile = '/test/shared/utils.rs' + + // Mock import extraction to return relative paths + mockedExtractImportPaths.mockImplementation((filePath: string) => { + if (filePath === mainFile1 || filePath === mainFile2) { + return ['../../../shared/utils'] + } + return [] + }) - const extension1Dir = joinPath(dir, 'extensions/function1') - const extension2Dir = joinPath(dir, 'extensions/function2') - const mainFile1 = joinPath(extension1Dir, 'src/main.rs') - const mainFile2 = joinPath(extension2Dir, 'src/main.rs') - const sharedFile = joinPath(dir, 'shared/utils.rs') + // Create test extensions + const testFunction1 = await testFunctionExtension({ + dir: extension1Dir, + }) + testFunction1.entrySourceFilePath = mainFile1 + // Mock watchedFiles to include the main file and shared file + vi.spyOn(testFunction1, 'watchedFiles').mockReturnValue([mainFile1, sharedFile]) - mockedExtractImportPaths.mockImplementation((filePath: string) => { - if (filePath === mainFile1 || filePath === mainFile2) { - return ['../../../shared/utils'] - } - return [] - }) + const testFunction2 = await testFunctionExtension({ + dir: extension2Dir, + }) + testFunction2.entrySourceFilePath = mainFile2 + // Mock watchedFiles to include the main file and shared file + vi.spyOn(testFunction2, 'watchedFiles').mockReturnValue([mainFile2, sharedFile]) - const testFunction1 = await testFunctionExtension({dir: extension1Dir}) - testFunction1.entrySourceFilePath = mainFile1 - vi.spyOn(testFunction1, 'watchedFiles').mockReturnValue([mainFile1, sharedFile]) + const app = testAppLinked({ + allExtensions: [testFunction1, testFunction2], + directory: '/test', + }) - const testFunction2 = await testFunctionExtension({dir: extension2Dir}) - testFunction2.entrySourceFilePath = mainFile2 - vi.spyOn(testFunction2, 'watchedFiles').mockReturnValue([mainFile2, sharedFile]) + // Mock chokidar - we need to check the paths passed to watch + let watchedPaths: string[] = [] + vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { + watchedPaths = paths as string[] + return { + on: vi.fn().mockReturnThis(), + close: vi.fn().mockResolvedValue(undefined), + } as any + }) - const app = testAppLinked({ - allExtensions: [testFunction1, testFunction2], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) - - let watchedPaths: string[] = [] - vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { - watchedPaths = paths as string[] - return { - on: vi.fn().mockReturnThis(), - close: vi.fn().mockResolvedValue(undefined), - } as any - }) - - const fileWatcher = new FileWatcher(app, outputOptions) - await fileWatcher.start() + const fileWatcher = new FileWatcher(app, outputOptions) + await fileWatcher.start() - const sharedFileCount = watchedPaths.filter((path) => path === normalizePath(sharedFile)).length - expect(sharedFileCount).toBe(1) - mockedExtractImportPaths.mockReset() - }) + // Check that shared file was included in the initial watch paths only once + const sharedFileCount = watchedPaths.filter((path) => path === sharedFile).length + expect(sharedFileCount).toBe(1) + + // Clean up + mockedExtractImportPaths.mockReset() }) test('rescans imports when a source file changes', async () => { - await inTemporaryDirectory(async (dir) => { - const mockedExtractImportPaths = extractImportPathsRecursively as any + const mockedExtractImportPaths = extractImportPathsRecursively as any - const extensionDir = joinPath(dir, 'extensions/my-function') - const mainFile = joinPath(extensionDir, 'src/main.rs') - const constantsFile = joinPath(dir, 'constants.rs') + const extensionDir = '/test/extensions/my-function' + const mainFile = joinPath(extensionDir, 'src', 'main.rs') + const constantsFile = '/test/constants.rs' - mockedExtractImportPaths.mockImplementation((filePath: string) => { - if (filePath === mainFile) { - return ['../constants'] - } - return [] - }) - - const testFunction = await testFunctionExtension({dir: extensionDir}) - testFunction.entrySourceFilePath = mainFile - vi.spyOn(testFunction, 'watchedFiles').mockReturnValue([mainFile, constantsFile]) - const rescanImportsSpy = vi.spyOn(testFunction, 'rescanImports').mockResolvedValue(true) + // Initially has import + mockedExtractImportPaths.mockImplementation((filePath: string) => { + if (filePath === mainFile) { + return ['../constants'] + } + return [] + }) - const app = testAppLinked({ - allExtensions: [testFunction], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) + const testFunction = await testFunctionExtension({ + dir: extensionDir, + }) + testFunction.entrySourceFilePath = mainFile - let watchedPaths: string[] = [] - const mockWatcher = { - on: vi.fn().mockReturnThis(), - close: vi.fn().mockResolvedValue(undefined), - } - vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { - watchedPaths = paths as string[] - return mockWatcher as any - }) + // Mock watchedFiles to include the main file and imported file + vi.spyOn(testFunction, 'watchedFiles').mockReturnValue([mainFile, '/test/constants.rs']) - const fileWatcher = new FileWatcher(app, outputOptions) - await fileWatcher.start() + // Mock the rescanImports method on the extension + const rescanImportsSpy = vi.spyOn(testFunction, 'rescanImports').mockResolvedValue(true) - expect(watchedPaths).toContain(normalizePath(mainFile)) - expect(watchedPaths).toContain(normalizePath(constantsFile)) + const app = testAppLinked({ + allExtensions: [testFunction], + directory: '/test', + }) - mockedExtractImportPaths.mockReset() - rescanImportsSpy.mockRestore() + // Mock chokidar with event capture + let eventHandler: any + let watchedPaths: string[] = [] + const mockWatcher = { + on: vi.fn((event: string, handler: any) => { + if (event === 'all') { + eventHandler = handler + } + return mockWatcher + }), + close: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { + watchedPaths = paths as string[] + return mockWatcher as any }) - }) - test('ignores imported files inside extension directories', async () => { - await inTemporaryDirectory(async (dir) => { - const mockedExtractImportPaths = extractImportPathsRecursively as any + const fileWatcher = new FileWatcher(app, outputOptions) + await fileWatcher.start() - const extensionDir = joinPath(dir, 'extensions/my-function') - const mainFile = joinPath(extensionDir, 'src/main.rs') - const utilsFile = joinPath(extensionDir, 'src/utils.rs') + // Initial paths should include the main file and imported file + expect(watchedPaths).toContain(mainFile) + expect(watchedPaths).toContain('/test/constants.rs') - mockedExtractImportPaths.mockImplementation((filePath: string) => { - if (filePath === mainFile) { - return [utilsFile] - } - return [] - }) + // Note: Since we're mocking watchedFiles directly, extractImportPathsRecursively + // won't be called. The actual rescanning of imports happens in app-event-watcher, + // not in the file watcher itself - const testFunction = await testFunctionExtension({dir: extensionDir}) - testFunction.entrySourceFilePath = mainFile + // Clean up + mockedExtractImportPaths.mockReset() + rescanImportsSpy.mockRestore() + }) - const app = testAppLinked({ - allExtensions: [testFunction], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) + test('ignores imported files inside extension directories', async () => { + const mockedExtractImportPaths = extractImportPathsRecursively as any - const mockWatcher = { - on: vi.fn().mockReturnThis(), - add: vi.fn(), - close: vi.fn().mockResolvedValue(undefined), + const extensionDir = '/test/extensions/my-function' + const mainFile = joinPath(extensionDir, 'src', 'main.rs') + const utilsFile = joinPath(extensionDir, 'src', 'utils.rs') + + // Mock import extraction to return the utils file + mockedExtractImportPaths.mockImplementation((filePath: string) => { + if (filePath === mainFile) { + return [utilsFile] } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + return [] + }) - const fileWatcher = new FileWatcher(app, outputOptions) - await fileWatcher.start() + const testFunction = await testFunctionExtension({ + dir: extensionDir, + }) + testFunction.entrySourceFilePath = mainFile - if (mockWatcher.add.mock.calls.length > 0) { - const allAddedFiles = mockWatcher.add.mock.calls.flat().flat() - expect(allAddedFiles).not.toContain(normalizePath(utilsFile)) - } - mockedExtractImportPaths.mockReset() + const app = testAppLinked({ + allExtensions: [testFunction], + directory: '/test', }) + + // Mock chokidar + const mockWatcher = { + on: vi.fn().mockReturnThis(), + add: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + + const fileWatcher = new FileWatcher(app, outputOptions) + await fileWatcher.start() + + // The watcher should not add files inside extension directories + if (mockWatcher.add.mock.calls.length > 0) { + const allAddedFiles = mockWatcher.add.mock.calls.flat().flat() + expect(allAddedFiles).not.toContain(utilsFile) + } + + // Clean up + mockedExtractImportPaths.mockReset() }) test('handles rapid file changes without hanging', async () => { - await inTemporaryDirectory(async (dir) => { - const app = testAppLinked({ - allExtensions: [], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) - - let eventHandler: any - const events: WatcherEvent[] = [] - const onChange = (newEvents: WatcherEvent[]) => { - events.push(...newEvents) - } + let eventHandler: any + const events: WatcherEvent[] = [] + const onChange = (newEvents: WatcherEvent[]) => { + events.push(...newEvents) + } - const mockWatcher = { - on: vi.fn((event: string, handler: any) => { - if (event === 'all') eventHandler = handler - return mockWatcher - }), - add: vi.fn(), - close: vi.fn().mockResolvedValue(undefined), - } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + const mockWatcher = { + on: vi.fn((event: string, handler: any) => { + if (event === 'all') { + eventHandler = handler + } + return mockWatcher + }), + add: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) - const fileWatcher = new FileWatcher(app, outputOptions) - fileWatcher.onChange(onChange) - await fileWatcher.start() + const fileWatcher = new FileWatcher(defaultApp, outputOptions) + fileWatcher.onChange(onChange) + await fileWatcher.start() - if (eventHandler) { - const configPath = joinPath(dir, 'shopify.app.toml') - await eventHandler('change', configPath) - await eventHandler('change', configPath) - await eventHandler('change', configPath) - } + // Trigger multiple rapid changes - testing debounce doesn't hang + if (eventHandler) { + await eventHandler('change', '/shopify.app.toml') + await eventHandler('change', '/shopify.app.toml') + await eventHandler('change', '/shopify.app.toml') + } - await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), {timeout: 1000, interval: 50}) - }) + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), {timeout: 1000, interval: 50}) }) }) test('creates extension directories if they do not exist before starting watcher', async () => { + const realFs = await vi.importActual('@shopify/cli-kit/node/fs') + await inTemporaryDirectory(async (dir) => { const extDir = joinPath(dir, 'extensions') const configPath = joinPath(dir, 'shopify.app.toml') - await writeFile(configPath, '') + await realFs.writeFile(configPath, '') const app = testAppLinked({ allExtensions: [], @@ -661,6 +729,9 @@ describe('file-watcher events', () => { }, }) + // Use real mkdir for this test + vi.mocked(mkdir).mockImplementation((path: string) => realFs.mkdir(path)) + const mockWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), @@ -670,15 +741,17 @@ describe('file-watcher events', () => { const fileWatcher = new FileWatcher(app, outputOptions) await fileWatcher.start() - expect(fileExistsSync(extDir)).toBe(true) + expect(realFs.fileExistsSync(extDir)).toBe(true) }) }) test('strips glob suffixes when creating extension directories', async () => { + const realFs = await vi.importActual('@shopify/cli-kit/node/fs') + await inTemporaryDirectory(async (dir) => { const extDir = joinPath(dir, 'extensions') const configPath = joinPath(dir, 'shopify.app.toml') - await writeFile(configPath, '') + await realFs.writeFile(configPath, '') const app = testAppLinked({ allExtensions: [], @@ -694,6 +767,8 @@ describe('file-watcher events', () => { }, }) + vi.mocked(mkdir).mockImplementation((path: string) => realFs.mkdir(path)) + const mockWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), @@ -703,206 +778,195 @@ describe('file-watcher events', () => { const fileWatcher = new FileWatcher(app, outputOptions) await fileWatcher.start() - expect(fileExistsSync(extDir)).toBe(true) - expect(fileExistsSync(joinPath(extDir, '**'))).toBe(false) + // Should create extensions/, not extensions/** + expect(realFs.fileExistsSync(extDir)).toBe(true) + expect(realFs.fileExistsSync(joinPath(extDir, '**'))).toBe(false) }) }) describe('runtime file discovery', () => { test('files added at runtime inside an existing extension trigger file_created', async () => { - await inTemporaryDirectory(async (dir) => { - const extension1 = await testUIExtension({ - type: 'ui_extension', - handle: 'h1', - directory: joinPath(dir, 'extensions/ui_extension_1'), - }) - const extension1B = await testUIExtension({ - type: 'ui_extension', - handle: 'h2', - directory: joinPath(dir, 'extensions/ui_extension_1'), - }) - - mockExtensionWatchedFiles(extension1, [joinPath(dir, 'extensions/ui_extension_1/index.js')]) - mockExtensionWatchedFiles(extension1B, [joinPath(dir, 'extensions/ui_extension_1/index.js')]) - - const testApp = testAppLinked({ - allExtensions: [extension1, extension1B], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - configuration: { - ...DEFAULT_CONFIG, - extension_directories: ['extensions'], - } as any, - }) - - let eventHandler: any - const mockWatcher = { - on: vi.fn((event: string, listener: any) => { - if (event === 'all') eventHandler = listener - return mockWatcher - }), - close: vi.fn(() => Promise.resolve()), - } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) - - const fileWatcher = new FileWatcher(testApp, outputOptions, 50) - const onChange = vi.fn() - fileWatcher.onChange(onChange) - await fileWatcher.start() - await flushPromises() + // Given: extension knows about index.js but NOT runtime-added.js + mockExtensionWatchedFiles(extension1, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension1B, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension2, []) + mockExtensionWatchedFiles(functionExtension, []) + mockExtensionWatchedFiles(posExtension, []) + mockExtensionWatchedFiles(appAccessExtension, []) + + const testApp = { + ...defaultApp, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } - const addedPath = joinPath(dir, 'extensions/ui_extension_1/runtime-added.js') - await eventHandler('add', addedPath, undefined) + let eventHandler: any + const mockWatcher = { + on: vi.fn((event: string, listener: any) => { + if (event === 'all') eventHandler = listener + return mockWatcher + }), + close: vi.fn(() => Promise.resolve()), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + vi.mocked(fileExistsSync).mockReturnValue(false) - await vi.waitFor( - () => { - const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] - if (!events) throw new Error('no events emitted') - expect(events).toHaveLength(2) - for (const event of events) { - expect(event.type).toBe('file_created') - expect(event.path).toBe(normalizePath(addedPath)) - expect(event.extensionPath).toBe(normalizePath(joinPath(dir, 'extensions/ui_extension_1'))) - } - const handles = events.map((event: WatcherEvent) => event.extensionHandle).sort() - expect(handles).toEqual(['h1', 'h2']) - }, - {timeout: 1000, interval: 50}, - ) - }) + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) + const onChange = vi.fn() + fileWatcher.onChange(onChange) + await fileWatcher.start() + await flushPromises() + + // When: a file the extension didn't pre-register is created on disk + await eventHandler('add', '/extensions/ui_extension_1/runtime-added.js', undefined) + + // Then: it's attributed to the owning extensions and emitted + await vi.waitFor( + () => { + const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] + if (!events) throw new Error('no events emitted') + expect(events).toHaveLength(2) + for (const event of events) { + expect(event.type).toBe('file_created') + expect(event.path).toBe('/extensions/ui_extension_1/runtime-added.js') + expect(event.extensionPath).toBe('/extensions/ui_extension_1') + } + const handles = events.map((event: WatcherEvent) => event.extensionHandle).sort() + expect(handles).toEqual(['h1', 'h2']) + }, + {timeout: 1000, interval: 50}, + ) }) test('files added at runtime outside any extension are ignored', async () => { - await inTemporaryDirectory(async (dir) => { - const testApp = testAppLinked({ - allExtensions: [], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - configuration: { - ...DEFAULT_CONFIG, - extension_directories: ['extensions'], - } as any, - }) - - let eventHandler: any - const mockWatcher = { - on: vi.fn((event: string, listener: any) => { - if (event === 'all') eventHandler = listener - return mockWatcher - }), - close: vi.fn(() => Promise.resolve()), - } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + mockExtensionWatchedFiles(extension1, []) + mockExtensionWatchedFiles(extension1B, []) + mockExtensionWatchedFiles(extension2, []) + mockExtensionWatchedFiles(functionExtension, []) + mockExtensionWatchedFiles(posExtension, []) + mockExtensionWatchedFiles(appAccessExtension, []) + + const testApp = { + ...defaultApp, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } - const fileWatcher = new FileWatcher(testApp, outputOptions, 50) - const onChange = vi.fn() - fileWatcher.onChange(onChange) - await fileWatcher.start() - await flushPromises() + let eventHandler: any + const mockWatcher = { + on: vi.fn((event: string, listener: any) => { + if (event === 'all') eventHandler = listener + return mockWatcher + }), + close: vi.fn(() => Promise.resolve()), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + vi.mocked(fileExistsSync).mockReturnValue(false) - await eventHandler('add', joinPath(dir, 'some/random/path/file.js'), undefined) + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) + const onChange = vi.fn() + fileWatcher.onChange(onChange) + await fileWatcher.start() + await flushPromises() - const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0) - expect(hasNonEmptyCall).toBe(false) - }) + await eventHandler('add', '/some/random/path/file.js', undefined) + + const hasNonEmptyCall = onChange.mock.calls.some((call) => call[0].length > 0) + expect(hasNonEmptyCall).toBe(false) }) test('subsequent change/unlink on a runtime-discovered file are not dropped', async () => { - await inTemporaryDirectory(async (dir) => { - const extension1 = await testUIExtension({ - type: 'ui_extension', - handle: 'h1', - directory: joinPath(dir, 'extensions/ui_extension_1'), - }) - mockExtensionWatchedFiles(extension1, [joinPath(dir, 'extensions/ui_extension_1/index.js')]) - - const testApp = testAppLinked({ - allExtensions: [extension1], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - configuration: { - ...DEFAULT_CONFIG, - extension_directories: ['extensions'], - } as any, - }) - - let eventHandler: any - const mockWatcher = { - on: vi.fn((event: string, listener: any) => { - if (event === 'all') eventHandler = listener - return mockWatcher - }), - close: vi.fn(() => Promise.resolve()), - } - vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) - - const fileWatcher = new FileWatcher(testApp, outputOptions, 50) - const onChange = vi.fn() - fileWatcher.onChange(onChange) - await fileWatcher.start() - await flushPromises() - - const addedPath = joinPath(dir, 'extensions/ui_extension_1/runtime-added.js') - await eventHandler('add', addedPath, undefined) - await vi.waitFor( - () => { - const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] - if (!events) throw new Error('no add events emitted') - expect(events.some((event: WatcherEvent) => event.type === 'file_created')).toBe(true) - }, - {timeout: 1000, interval: 50}, - ) + mockExtensionWatchedFiles(extension1, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension1B, ['/extensions/ui_extension_1/index.js']) + mockExtensionWatchedFiles(extension2, []) + mockExtensionWatchedFiles(functionExtension, []) + mockExtensionWatchedFiles(posExtension, []) + mockExtensionWatchedFiles(appAccessExtension, []) + + const testApp = { + ...defaultApp, + allExtensions: defaultApp.allExtensions, + nonConfigExtensions: defaultApp.allExtensions.filter((ext) => !ext.isAppConfigExtension), + realExtensions: defaultApp.allExtensions, + } - onChange.mockClear() - await eventHandler('change', addedPath, undefined) + let eventHandler: any + const mockWatcher = { + on: vi.fn((event: string, listener: any) => { + if (event === 'all') eventHandler = listener + return mockWatcher + }), + close: vi.fn(() => Promise.resolve()), + } + vi.spyOn(chokidar, 'watch').mockReturnValue(mockWatcher as any) + vi.mocked(fileExistsSync).mockReturnValue(false) - await vi.waitFor( - () => { - const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] - if (!events) throw new Error('no change events emitted') - expect(events.some((event: WatcherEvent) => event.type === 'file_updated')).toBe(true) - }, - {timeout: 1000, interval: 50}, - ) - }) + const fileWatcher = new FileWatcher(testApp, outputOptions, 50) + const onChange = vi.fn() + fileWatcher.onChange(onChange) + await fileWatcher.start() + await flushPromises() + + // Discover the file via 'add' + await eventHandler('add', '/extensions/ui_extension_1/runtime-added.js', undefined) + await vi.waitFor( + () => { + const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] + if (!events) throw new Error('no add events emitted') + expect(events.some((event: WatcherEvent) => event.type === 'file_created')).toBe(true) + }, + {timeout: 1000, interval: 50}, + ) + + // Now fire a 'change' on the same path; should produce a file_updated event + onChange.mockClear() + await eventHandler('change', '/extensions/ui_extension_1/runtime-added.js', undefined) + + await vi.waitFor( + () => { + const events = onChange.mock.calls.find((call) => call[0].length > 0)?.[0] + if (!events) throw new Error('no change events emitted') + expect(events.some((event: WatcherEvent) => event.type === 'file_updated')).toBe(true) + }, + {timeout: 1000, interval: 50}, + ) }) }) describe('refreshWatchedFiles', () => { test('closes and recreates the watcher with updated paths', async () => { - await inTemporaryDirectory(async (dir) => { - const app = testAppLinked({ - allExtensions: [], - directory: dir, - configPath: joinPath(dir, 'shopify.app.toml'), - }) - - const mockClose = vi.fn().mockResolvedValue(undefined) - let watchCalls = 0 - const watchedPaths: string[][] = [] - - vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { - watchCalls++ - watchedPaths.push(paths as string[]) - return { - on: vi.fn().mockReturnThis(), - add: vi.fn(), - close: mockClose, - } as any - }) - - const fileWatcher = new FileWatcher(app, outputOptions) - await fileWatcher.start() + // Given + const mockClose = vi.fn().mockResolvedValue(undefined) + let watchCalls = 0 + const watchedPaths: string[][] = [] + + vi.spyOn(chokidar, 'watch').mockImplementation((paths) => { + watchCalls++ + watchedPaths.push(paths as string[]) + return { + on: vi.fn().mockReturnThis(), + add: vi.fn(), + close: mockClose, + } as any + }) - expect(watchCalls).toBe(1) - expect(mockClose).not.toHaveBeenCalled() + const fileWatcher = new FileWatcher(defaultApp, outputOptions) + await fileWatcher.start() - await fileWatcher.start() + // Initial watcher should be created + expect(watchCalls).toBe(1) + expect(mockClose).not.toHaveBeenCalled() - expect(mockClose).toHaveBeenCalledTimes(1) - expect(watchCalls).toBe(2) - expect(watchedPaths[1]).toEqual(watchedPaths[0]) - }) + // When refreshing + await fileWatcher.start() + + // Then + expect(mockClose).toHaveBeenCalledTimes(1) + expect(watchCalls).toBe(2) + // Should have same paths + expect(watchedPaths[1]).toEqual(watchedPaths[0]) }) }) }) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 1277bb675d0..f0ec1c92b1c 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -169,7 +169,6 @@ export async function setupDevProcesses({ storeFqdn, theme: commandOptions.theme, themeExtensionPort: commandOptions.themeExtensionPort, - storePassword: commandOptions.storePassword, }), setupSendUninstallWebhookProcess({ webs: reloadedApp.webs, diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts index 166a1322b0a..870405d9f4d 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts @@ -212,24 +212,6 @@ describe('setupPreviewThemeAppExtensionsProcess', () => { expect(result!.options.storefrontPassword).toEqual(storefrontPassword) expect(result!.options.crawlerSignatureHeaders).toEqual(crawlerSignatureHeaders) }) - - test('uses the provided store password when one is required', async () => { - const mockTheme = {id: 123} as Theme - vi.mocked(fetchTheme).mockResolvedValue(mockTheme) - vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) - vi.mocked(ensureValidPassword).mockResolvedValue('validated-password') - - const result = await setupPreviewThemeAppExtensionsProcess({ - localApp: testApp({allExtensions: [await testThemeExtensions()]}), - remoteApp: testOrganizationApp(), - storeFqdn: 'test.myshopify.com', - theme: '1', - storePassword: 'provided-password', - }) - - expect(ensureValidPassword).toHaveBeenCalledWith('provided-password', 'test.myshopify.com', crawlerSignatureHeaders) - expect(result!.options.storefrontPassword).toEqual('validated-password') - }) }) describe('findOrCreateHostTheme', () => { diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts index d4065b78d90..c9147f3505b 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts @@ -33,7 +33,6 @@ interface HostThemeSetupOptions { storeFqdn: string theme?: string themeExtensionPort?: number - storePassword?: string } export interface PreviewThemeAppExtensionsProcess extends BaseProcess { @@ -65,7 +64,7 @@ export async function setupPreviewThemeAppExtensionsProcess( isStorefrontPasswordProtected(adminSession), ]) const storefrontPassword = isPasswordProtected - ? await ensureValidPassword(options.storePassword, storeFqdn, crawlerSignatureHeaders) + ? await ensureValidPassword(undefined, storeFqdn, crawlerSignatureHeaders) : undefined const theme = await findOrCreateHostTheme(adminSession, options.theme) diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index 68a1ad446da..75f88a12268 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -1,6 +1,10 @@ import {selectStore} from './select-store.js' import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' -import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' +import { + reloadStoreListPrompt, + selectStorePrompt, + confirmConversionToTransferDisabledStorePrompt, +} from '../../prompts/dev.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {ClientName} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' @@ -89,25 +93,51 @@ describe('selectStore', async () => { ) }) - test('throws if selected store is not transfer-disabled', async () => { + test('prompts user to convert store to non-transferable if selection is invalid', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) + vi.mocked(confirmConversionToTransferDisabledStorePrompt).mockResolvedValueOnce(true) // When - const got = selectStore( + const got = await selectStore( {stores: [STORE1, STORE2], hasMorePages: false}, ORG1, testDeveloperPlatformClient({clientName: ClientName.Partners}), ) // Then - await expect(got).rejects.toThrow('The store you specified (domain2) is not transfer-disabled') + expect(got).toEqual(STORE2) + expect(selectStorePrompt).toHaveBeenCalledWith( + expect.objectContaining({ + stores: [STORE1, STORE2], + showDomainOnPrompt: defaultShowDomainOnPrompt, + }), + ) + expect(confirmConversionToTransferDisabledStorePrompt).toHaveBeenCalled() + }) + + test('choosing not to convert to transfer-disabled forces another prompt', async () => { + // Given + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + vi.mocked(confirmConversionToTransferDisabledStorePrompt).mockResolvedValueOnce(false) + + // When + const got = await selectStore( + {stores: [STORE1, STORE2], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.Partners}), + ) + + // Then + expect(got).toEqual(STORE1) expect(selectStorePrompt).toHaveBeenCalledWith( expect.objectContaining({ stores: [STORE1, STORE2], showDomainOnPrompt: defaultShowDomainOnPrompt, }), ) + expect(confirmConversionToTransferDisabledStorePrompt).toHaveBeenCalled() }) test('throws if store is non convertible', async () => { diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index f201d696d53..000ef277971 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -1,9 +1,18 @@ import {Organization, OrganizationStore} from '../../models/organization.js' -import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' +import { + confirmConversionToTransferDisabledStorePrompt, + reloadStoreListPrompt, + selectStorePrompt, +} from '../../prompts/dev.js' +import { + ConvertDevToTransferDisabledSchema, + ConvertDevToTransferDisabledStoreVariables, +} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {ClientName, DeveloperPlatformClient, Paginateable} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' import {renderInfo, renderTasks} from '@shopify/cli-kit/node/ui' -import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' +import {AbortError, BugError, CancelExecution} from '@shopify/cli-kit/node/error' +import {outputSuccess} from '@shopify/cli-kit/node/output' /** * Select store from list or @@ -22,8 +31,8 @@ export async function selectStore( ): Promise { const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) - // If no stores, guide the developer through creating one. - // Then, with a store selected, make sure it's transfer-disabled. + // If no stores, guide the developer through creating one + // Then, with a store selected, make sure its transfer-disabled, prompting to convert if needed let store = await selectStorePrompt({ onSearchForStoresByName, ...storesSearch, @@ -44,7 +53,21 @@ export async function selectStore( store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) } - ensureTransferDisabledStore(store) + let storeIsValid = await convertToTransferDisabledStoreIfNeeded( + store, + org.id, + developerPlatformClient, + 'prompt-first', + ) + while (!storeIsValid) { + // eslint-disable-next-line no-await-in-loop + store = await selectStorePrompt({stores: [store], hasMorePages: false, showDomainOnPrompt}) + if (!store) { + throw new CancelExecution() + } + // eslint-disable-next-line no-await-in-loop + storeIsValid = await convertToTransferDisabledStoreIfNeeded(store, org.id, developerPlatformClient, 'prompt-first') + } return store } @@ -87,13 +110,23 @@ async function waitForCreatedStore( /** * Check if the store exists in the current organization and it is a valid store - * To be valid, it must be transfer-disabled. + * To be valid, it must be non-transferable. This can't be undone, so we ask the user to confirm. * - * @param store - Store to check - * @throws If the store is not eligible for `app dev` + * @param storeDomain - Store domain to check + * @param stores - List of available stores + * @param orgId - Current organization ID + * @param developerPlatformClient - The client to access the platform API + * @param conversionMode - Whether to prompt the user to convert the store to a transfer-disabled store, or fail if it's not + * @returns False, if the store is invalid and the user chose not to convert it. Otherwise true. + * @throws If the store can't be found in the organization or we fail to make it a transfer-disabled store */ -export function ensureTransferDisabledStore(store: OrganizationStore): void { - if (store.transferDisabled) return +export async function convertToTransferDisabledStoreIfNeeded( + store: OrganizationStore, + orgId: string, + developerPlatformClient: DeveloperPlatformClient, + conversionMode: 'prompt-first' | 'never', +): Promise { + if (store.transferDisabled) return true if (!store.transferDisabled && !store.convertableToPartnerTest) { throw new AbortError( @@ -102,8 +135,50 @@ export function ensureTransferDisabledStore(store: OrganizationStore): void { ) } - throw new AbortError( - `The store you specified (${store.shopDomain}) is not transfer-disabled`, - 'Run dev --reset and select a transfer-disabled dev store.', - ) + switch (conversionMode) { + case 'prompt-first': { + const confirmed = await confirmConversionToTransferDisabledStorePrompt() + if (!confirmed) { + // tell caller the store is invalid and not converted. they may re-prompt etc. + return false + } + await convertStoreToTransferDisabled(store, orgId, developerPlatformClient) + return true + } + case 'never': { + throw new AbortError( + 'The store you specified is not transfer-disabled', + "Try running 'dev --reset' and selecting a different store, or choosing to convert this one.", + ) + } + } +} + +/** + * Convert a store to a transfer-disabled store so development apps can be installed + * @param store - Store to convert + * @param orgId - Current organization ID + * @param developerPlatformClient - The client to access the platform API + */ +async function convertStoreToTransferDisabled( + store: OrganizationStore, + orgId: string, + developerPlatformClient: DeveloperPlatformClient, +) { + const variables: ConvertDevToTransferDisabledStoreVariables = { + input: { + organizationID: parseInt(orgId, 10), + shopId: store.shopId, + }, + } + const result: ConvertDevToTransferDisabledSchema = + await developerPlatformClient.convertToTransferDisabledStore(variables) + if (!result.convertDevToTestStore.convertedToTestStore) { + const errors = result.convertDevToTestStore.userErrors.map((error) => error.message).join(', ') + throw new BugError( + `Error converting store ${store.shopDomain} to a transfer-disabled store: ${errors}`, + 'This store might not be compatible with draft apps, please try a different store', + ) + } + outputSuccess(`Converted ${store.shopDomain} to a transfer-disabled store`) } diff --git a/packages/app/src/cli/services/extensions/bundle.test.ts b/packages/app/src/cli/services/extensions/bundle.test.ts index 712f07f5df6..515872041a6 100644 --- a/packages/app/src/cli/services/extensions/bundle.test.ts +++ b/packages/app/src/cli/services/extensions/bundle.test.ts @@ -4,9 +4,17 @@ import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-sp import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {describe, expect, test, vi} from 'vitest' import {context as esContext} from 'esbuild' -import {glob, inTemporaryDirectory, mkdir, touchFileSync, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' +import {glob, inTemporaryDirectory, mkdir, touchFileSync, writeFile} from '@shopify/cli-kit/node/fs' import {basename, joinPath} from '@shopify/cli-kit/node/path' +vi.mock('@shopify/cli-kit/node/fs', async () => { + const actual: any = await vi.importActual('@shopify/cli-kit/node/fs') + return { + ...actual, + writeFile: vi.fn(), + } +}) + vi.mock('esbuild', async () => { const esbuild: any = await vi.importActual('esbuild') return { @@ -119,103 +127,91 @@ describe('bundleExtension()', () => { }) test('writes metafile to disk for production builds', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const extension = await testUIExtension({directory: tmpDir}) - const stdout: any = { - write: vi.fn(), - } - const stderr: any = { - write: vi.fn(), - } - const esbuildRebuild = vi.fn(esbuildResultFixture) - - vi.mocked(esContext).mockResolvedValue({ - rebuild: esbuildRebuild, - watch: vi.fn(), - dispose: vi.fn(), - cancel: vi.fn(), - serve: vi.fn(), - }) + // Given + const extension = await testUIExtension() + const stdout: any = { + write: vi.fn(), + } + const stderr: any = { + write: vi.fn(), + } + const esbuildRebuild = vi.fn(esbuildResultFixture) - const distPath = joinPath(extension.directory, 'dist') - await mkdir(distPath) - extension.outputPath = joinPath(distPath, 'test-ui-extension.js') + vi.mocked(esContext).mockResolvedValue({ + rebuild: esbuildRebuild, + watch: vi.fn(), + dispose: vi.fn(), + cancel: vi.fn(), + serve: vi.fn(), + }) - // When - await bundleExtension({ - env: {}, - outputPath: extension.outputPath, - minify: true, - environment: 'production', - stdin: { - contents: 'console.log("mock stdin content")', - resolveDir: 'mock/resolve/dir', - loader: 'tsx', - }, - stdout, - stderr, - }) + // When + await bundleExtension({ + env: {}, + outputPath: extension.outputPath, + minify: true, + environment: 'production', + stdin: { + contents: 'console.log("mock stdin content")', + resolveDir: 'mock/resolve/dir', + loader: 'tsx', + }, + stdout, + stderr, + }) - // Then - const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0] - expect(esbuildOptions.metafile).toBe(true) + // Then + const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0] + expect(esbuildOptions.metafile).toBe(true) - const metafilePath = joinPath(extension.directory, 'dist', 'test-ui-extension.metafile.json') - const content = await readFile(metafilePath) - expect(JSON.parse(content)).toEqual({inputs: {}, outputs: {}}) - }) + expect(writeFile).toHaveBeenCalledWith( + joinPath(extension.directory, 'dist', 'test-ui-extension.metafile.json'), + JSON.stringify({inputs: {}, outputs: {}}), + ) }) test('does not write metafile to disk for development builds', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const extension = await testUIExtension({directory: tmpDir}) - const stdout: any = { - write: vi.fn(), - } - const stderr: any = { - write: vi.fn(), - } - const esbuildRebuild = vi.fn(async () => { - const result = await esbuildResultFixture() - return {...result, metafile: undefined} - }) - - vi.mocked(esContext).mockResolvedValue({ - rebuild: esbuildRebuild, - watch: vi.fn(), - dispose: vi.fn(), - cancel: vi.fn(), - serve: vi.fn(), - }) + // Given + const extension = await testUIExtension() + const stdout: any = { + write: vi.fn(), + } + const stderr: any = { + write: vi.fn(), + } + const esbuildRebuild = vi.fn(async () => { + const result = await esbuildResultFixture() + return {...result, metafile: undefined} + }) - const distPath = joinPath(extension.directory, 'dist') - await mkdir(distPath) - extension.outputPath = joinPath(distPath, 'test-ui-extension.js') + vi.mocked(esContext).mockResolvedValue({ + rebuild: esbuildRebuild, + watch: vi.fn(), + dispose: vi.fn(), + cancel: vi.fn(), + serve: vi.fn(), + }) - // When - await bundleExtension({ - env: {}, - outputPath: extension.outputPath, - minify: false, - environment: 'development', - stdin: { - contents: 'console.log("mock stdin content")', - resolveDir: 'mock/resolve/dir', - loader: 'tsx', - }, - stdout, - stderr, - }) + // When + await bundleExtension({ + env: {}, + outputPath: extension.outputPath, + minify: false, + environment: 'development', + stdin: { + contents: 'console.log("mock stdin content")', + resolveDir: 'mock/resolve/dir', + loader: 'tsx', + }, + stdout, + stderr, + }) - // Then - const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0] - expect(esbuildOptions.metafile).toBeUndefined() + // Then + const esbuildOptions = vi.mocked(esContext).mock.calls[0]![0] + expect(esbuildOptions.metafile).toBeUndefined() - const metafilePath = joinPath(extension.directory, 'dist', 'test-ui-extension.metafile.json') - expect(fileExistsSync(metafilePath)).toBe(false) - }) + expect(writeFile).not.toHaveBeenCalled() }) test('can switch off React deduplication', async () => { diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 7312aeb52c1..bb83f8538da 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -1,6 +1,6 @@ import {storeContext} from './store-context.js' import {fetchStore} from './dev/fetch.js' -import {ensureTransferDisabledStore, selectStore} from './dev/select-store.js' +import {convertToTransferDisabledStoreIfNeeded, selectStore} from './dev/select-store.js' import {LoadedAppContextOutput} from './app-context.js' import { testAppLinked, @@ -66,7 +66,12 @@ describe('storeContext', () => { mockDeveloperPlatformClient, ['APP_DEVELOPMENT'], ) - expect(ensureTransferDisabledStore).toHaveBeenCalledWith(mockStore) + expect(convertToTransferDisabledStoreIfNeeded).toHaveBeenCalledWith( + mockStore, + mockOrganization.id, + mockDeveloperPlatformClient, + 'never', + ) expect(result).toEqual(mockStore) }) }) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 05f9f9b3673..42827d58bb1 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -1,5 +1,5 @@ import {fetchStore} from './dev/fetch.js' -import {ensureTransferDisabledStore, selectStore} from './dev/select-store.js' +import {convertToTransferDisabledStoreIfNeeded, selectStore} from './dev/select-store.js' import {LoadedAppContextOutput} from './app-context.js' import {OrganizationStore} from '../models/organization.js' import {Store} from '../utilities/developer-platform-client.js' @@ -54,8 +54,9 @@ export async function storeContext({ if (storeFqdnToUse) { selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient, storeTypes) + // never automatically convert a store provided via the command line if (isDevStoresOnly) { - ensureTransferDisabledStore(selectedStore) + await convertToTransferDisabledStoreIfNeeded(selectedStore, organization.id, developerPlatformClient, 'never') } } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index 4a7d0095f29..c4b8a2a67e4 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -118,7 +118,7 @@ describe('validateApp', () => { test('renders success when there are no errors', async () => { const app = testAppLinked() await validateApp(app) - expect(renderSuccess).toHaveBeenCalledWith({headline: "App configuration 'shopify.app.toml' is valid."}) + expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) expect(renderError).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() await expectLastValidationMetadata({ @@ -197,7 +197,7 @@ describe('validateApp', () => { const app = testAppLinked() app.errors = errors await validateApp(app) - expect(renderSuccess).toHaveBeenCalledWith({headline: "App configuration 'shopify.app.toml' is valid."}) + expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) expect(outputResult).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts index 4aeaaa74d66..7655c369a5b 100644 --- a/packages/app/src/cli/services/validate.ts +++ b/packages/app/src/cli/services/validate.ts @@ -4,7 +4,6 @@ import metadata from '../metadata.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {basename} from '@shopify/cli-kit/node/path' interface ValidateAppOptions { json: boolean @@ -31,7 +30,7 @@ export async function validateApp(app: AppLinkedInterface, options: ValidateAppO return } - renderSuccess({headline: `App configuration '${basename(app.configPath)}' is valid.`}) + renderSuccess({headline: 'App configuration is valid.'}) return } diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index dac97fd382a..f092e6d1d1b 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -11,6 +11,10 @@ import { import {AllAppExtensionRegistrationsQuerySchema} from '../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../api/graphql/app_deploy.js' +import { + ConvertDevToTransferDisabledSchema, + ConvertDevToTransferDisabledStoreVariables, +} from '../api/graphql/convert_dev_to_transfer_disabled_store.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' import {AppReleaseSchema} from '../api/graphql/app_release.js' import {AppVersionsDiffSchema} from '../api/graphql/app_versions_diff.js' @@ -253,6 +257,9 @@ export interface DeveloperPlatformClient { generateSignedUploadUrl: (app: MinimalAppIdentifiers) => Promise deploy: (input: AppDeployOptions) => Promise release: (input: {app: MinimalOrganizationApp; version: AppVersionIdentifiers}) => Promise + convertToTransferDisabledStore: ( + input: ConvertDevToTransferDisabledStoreVariables, + ) => Promise sendSampleWebhook: (input: SendSampleWebhookVariables, organizationId: string) => Promise apiVersions: (organizationId: string) => Promise topics: (input: WebhookTopicsVariables, organizationId: string) => Promise diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 81eaf08cf7e..ce225c38957 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -1609,102 +1609,6 @@ describe('storeByDomain', () => { }, }) }) - - test('returns the exact-domain match when the fuzzy search also returns substring matches', async () => { - // Given - // The Business Platform `accessibleShops` query treats `domain` as a fuzzy `search` argument, so - // a request for `example.myshopify.com` also returns `turbo-example.myshopify.com`, and the - // backend may rank the wrong (substring superset) store first. - const orgGid = 'gid://shopify/Organization/123' - const shopDomain = 'example.myshopify.com' - const token = 'business-platform-token' - const response: FetchStoreByDomainQuery = { - organization: { - id: orgGid, - name: 'Org 123', - accessibleShops: { - edges: [ - { - node: { - id: 'gid://BusinessPlatform/Shop/1', - externalId: encodedGidFromShopId('1'), - name: 'Turbo Example', - storeType: 'APP_DEVELOPMENT', - primaryDomain: 'turbo-example.myshopify.com', - shortName: 'turbo-example', - url: 'https://turbo-example.myshopify.com', - }, - }, - { - node: { - id: 'gid://BusinessPlatform/Shop/2', - externalId: encodedGidFromShopId('2'), - name: 'Example', - storeType: 'APP_DEVELOPMENT', - primaryDomain: shopDomain, - shortName: 'example', - url: `https://${shopDomain}`, - }, - }, - ], - }, - currentUser: {organizationPermissions: ['ondemand_access_to_stores']}, - }, - } - vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(response) - - const client = AppManagementClient.getInstance() - client.businessPlatformToken = () => Promise.resolve(token) - - // When - const result = await client.storeByDomain(orgGid, shopDomain, ['APP_DEVELOPMENT']) - - // Then - expect(result).toMatchObject({ - shopDomain, - shopName: 'Example', - storeType: 'APP_DEVELOPMENT', - }) - }) - - test('returns undefined when the fuzzy search only returns substring (non-exact) matches', async () => { - // Given - const orgGid = 'gid://shopify/Organization/123' - const shopDomain = 'example.myshopify.com' - const token = 'business-platform-token' - const response: FetchStoreByDomainQuery = { - organization: { - id: orgGid, - name: 'Org 123', - accessibleShops: { - edges: [ - { - node: { - id: 'gid://BusinessPlatform/Shop/1', - externalId: encodedGidFromShopId('1'), - name: 'Turbo Example', - storeType: 'APP_DEVELOPMENT', - primaryDomain: 'turbo-example.myshopify.com', - shortName: 'turbo-example', - url: 'https://turbo-example.myshopify.com', - }, - }, - ], - }, - currentUser: {organizationPermissions: []}, - }, - } - vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(response) - - const client = AppManagementClient.getInstance() - client.businessPlatformToken = () => Promise.resolve(token) - - // When - const result = await client.storeByDomain(orgGid, shopDomain, ['APP_DEVELOPMENT']) - - // Then - expect(result).toBeUndefined() - }) }) describe('ensureUserAccessToStore', () => { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index a6e1d30c822..07b32c020bd 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -44,6 +44,10 @@ import { } from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema} from '../../api/graphql/app_deploy.js' import {AppVersionsQuerySchema as AppVersionsQuerySchemaInterface} from '../../api/graphql/get_versions_list.js' +import { + ConvertDevToTransferDisabledSchema, + ConvertDevToTransferDisabledStoreVariables, +} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {AppReleaseSchema} from '../../api/graphql/app_release.js' import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' import { @@ -125,7 +129,6 @@ import { AppLogsSubscribeMutationVariables, } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' -import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js' import {fetchOrganizations} from '@shopify/organizations' import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' @@ -148,7 +151,6 @@ import { } from '@shopify/cli-kit/node/api/business-platform' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {encodeGid, numericIdFromEncodedGid, numericIdFromGid} from '@shopify/cli-kit/common/gid' -import {uniq} from '@shopify/cli-kit/common/array' import {versionSatisfies} from '@shopify/cli-kit/node/node-package-manager' import {outputDebug} from '@shopify/cli-kit/node/output' import {getCurrentCommandId} from '@shopify/cli-kit/node/global-context' @@ -443,7 +445,7 @@ export class AppManagementClient implements DeveloperPlatformClient { managementExperience: 'cli', registrationLimit: spec.uidStrategy.appModuleLimit, uidStrategy: uidStrategyFromTypename(spec.uidStrategy.__typename), - experience: normalizeExperience(spec.experience, spec.identifier), + experience: normalizeExperience(spec.experience), validationSchema: spec.validationSchema, }), ) @@ -685,7 +687,7 @@ export class AppManagementClient implements DeveloperPlatformClient { registrationTitle: mod.handle, specification: { identifier: mod.specification.identifier, - experience: normalizeExperience(mod.specification.experience, mod.specification.identifier), + experience: normalizeExperience(mod.specification.experience), options: { managementExperience: 'cli', }, @@ -861,13 +863,7 @@ export class AppManagementClient implements DeveloperPlatformClient { const provisionable = isStoreProvisionable(org.currentUser?.organizationPermissions ?? []) return mapBusinessPlatformStoresToOrganizationStores(nodes, provisionable) }) - - // `accessibleShops` treats `domain` as a fuzzy, free-text `search` argument, so a domain that is - // a substring of another store's domain (e.g. `example.myshopify.com` vs - // `turbo-example.myshopify.com`) can return multiple hits. Return only the store whose domain - // matches exactly, otherwise we risk connecting to the wrong store. - const normalizedDomain = normalizeStoreFqdn(shopDomain) - return stores.find((store) => store.shopDomain === normalizedDomain) + return stores[0] } async ensureUserAccessToStore(orgId: string, store: OrganizationStore): Promise { @@ -893,6 +889,12 @@ export class AppManagementClient implements DeveloperPlatformClient { } } + async convertToTransferDisabledStore( + _input: ConvertDevToTransferDisabledStoreVariables, + ): Promise { + throw new BugError('Not implemented: convertToTransferDisabledStore') + } + async sendSampleWebhook(input: SendSampleWebhookVariables, organizationId: string): Promise { const query = CliTesting const variables = { @@ -1322,8 +1324,8 @@ export async function allowedTemplates( version: string = CLI_KIT_VERSION, ): Promise { // Extract both types of flags from templates - const allBetaFlags = uniq(templates.flatMap((ext) => ext.organizationBetaFlags ?? [])) - const allExpFlags = uniq(templates.flatMap((ext) => ext.organizationExpFlags ?? [])) + const allBetaFlags = Array.from(new Set(templates.map((ext) => ext.organizationBetaFlags ?? []).flat())) + const allExpFlags = Array.from(new Set(templates.map((ext) => ext.organizationExpFlags ?? []).flat())) // Fetch both flag types in parallel const [enabledBetaFlags, enabledExpFlags] = await Promise.all([ @@ -1362,8 +1364,7 @@ type SpecificationExperience = 'extension' | 'configuration' | 'deprecated' * Normalizes the raw experience string from the API into a known union value. * Falls back to 'extension' for any unexpected/unknown values and logs a debug message. */ -function normalizeExperience(raw: string, type: string): SpecificationExperience { - if (type === WebhookSubscriptionSpecIdentifier) return 'configuration' +function normalizeExperience(raw: string): SpecificationExperience { switch (raw) { case 'extension': case 'configuration': @@ -1421,7 +1422,7 @@ function appModuleVersion(mod: ReleasedAppModuleFragment): Required { + return this.request(ConvertDevToTransferDisabledStoreQuery, input) + } + async storeByDomain(orgId: string, shopDomain: string, _storeTypes: Store[]): Promise { // Note: storeTypes filtering not implemented for PartnersClient const variables: FindStoreByDomainQueryVariables = {orgId, shopDomain} diff --git a/packages/app/src/cli/utilities/mkcert.test.ts b/packages/app/src/cli/utilities/mkcert.test.ts index 36940aa85fa..02b396cb396 100644 --- a/packages/app/src/cli/utilities/mkcert.test.ts +++ b/packages/app/src/cli/utilities/mkcert.test.ts @@ -216,44 +216,6 @@ describe('mkcert', () => { expect(downloadGitHubRelease).not.toHaveBeenCalled() }) - testWithTempDir('generates a certificate without prompting when forceInstall is true', async ({tempDir}) => { - const appDirectory = tempDir - vi.mocked(generateCertificatePrompt).mockClear() - vi.mocked(exec).mockClear() - - vi.mocked(exec).mockImplementation(async () => { - await mkdir(joinPath(appDirectory, '.shopify')) - await writeFile(joinPath(appDirectory, '.shopify', 'localhost-key.pem'), 'key') - await writeFile(joinPath(appDirectory, '.shopify', 'localhost.pem'), 'cert') - }) - - await generateCertificate({ - appDirectory, - forceInstall: true, - platform: 'linux', - }) - - expect(generateCertificatePrompt).not.toHaveBeenCalled() - expect(exec).toHaveBeenCalled() - }) - - testWithTempDir( - 'fails without prompting when forceInstall is false and certificates are missing', - async ({tempDir}) => { - vi.mocked(generateCertificatePrompt).mockClear() - vi.mocked(exec).mockClear() - const generatePromise = generateCertificate({ - appDirectory: tempDir, - forceInstall: false, - platform: 'linux', - }) - - await expect(generatePromise).rejects.toThrow(AbortError) - expect(generateCertificatePrompt).not.toHaveBeenCalled() - expect(exec).not.toHaveBeenCalled() - }, - ) - testWithTempDir('skips certificate generation if the certificate already exists', async ({tempDir}) => { const appDirectory = tempDir await mkdir(joinPath(appDirectory, '.shopify')) diff --git a/packages/app/src/cli/utilities/mkcert.ts b/packages/app/src/cli/utilities/mkcert.ts index f0ea73f3bd0..b5f97f70dcd 100644 --- a/packages/app/src/cli/utilities/mkcert.ts +++ b/packages/app/src/cli/utilities/mkcert.ts @@ -121,7 +121,6 @@ async function downloadMkcertLicense(dotShopifyPath: string): Promise { @@ -21,33 +20,3 @@ describe('LinkContentToken', () => { expect(got.output()).toEqual(fallback) }) }) - -describe('LinesDiffContentToken', () => { - test('formats added and removed lines correctly', () => { - // Given - const changes = [ - {value: 'same\n', count: 1}, - {value: 'removed\n', count: 1, removed: true}, - {value: 'added\n', count: 1, added: true}, - ] - const token = new LinesDiffContentToken(changes) - - // When - const output = token.output() - - // Then - expect(output).toEqual(['same\n', colors.magenta('- removed\n'), colors.green('+ added\n')]) - }) - - test('handles multiple lines in a single change part', () => { - // Given - const changes = [{value: 'added1\nadded2\n', count: 2, added: true}] - const token = new LinesDiffContentToken(changes) - - // When - const output = token.output() - - // Then - expect(output).toEqual([colors.green('+ added1\n'), colors.green('+ added2\n')]) - }) -}) diff --git a/packages/cli-kit/src/private/node/content-tokens.ts b/packages/cli-kit/src/private/node/content-tokens.ts index affc764eeb3..5f2cbb5eee3 100644 --- a/packages/cli-kit/src/private/node/content-tokens.ts +++ b/packages/cli-kit/src/private/node/content-tokens.ts @@ -63,25 +63,27 @@ export class JsonContentToken extends ContentToken { export class LinesDiffContentToken extends ContentToken { output(): string[] { - return this.value.flatMap((part) => { - if (part.added) { - return part.value - .split(/\n/) - .filter((line) => line !== '') - .map((line) => { - return colors.green(`+ ${line}\n`) - }) - } else if (part.removed) { - return part.value - .split(/\n/) - .filter((line) => line !== '') - .map((line) => { - return colors.magenta(`- ${line}\n`) - }) - } else { - return part.value - } - }) + return this.value + .map((part) => { + if (part.added) { + return part.value + .split(/\n/) + .filter((line) => line !== '') + .map((line) => { + return colors.green(`+ ${line}\n`) + }) + } else if (part.removed) { + return part.value + .split(/\n/) + .filter((line) => line !== '') + .map((line) => { + return colors.magenta(`- ${line}\n`) + }) + } else { + return part.value + } + }) + .flat() } } diff --git a/packages/cli-kit/src/private/node/session/scopes.ts b/packages/cli-kit/src/private/node/session/scopes.ts index 3a73b2d98c5..109f65f9726 100644 --- a/packages/cli-kit/src/private/node/session/scopes.ts +++ b/packages/cli-kit/src/private/node/session/scopes.ts @@ -1,6 +1,5 @@ import {allAPIs, API} from '../api.js' import {BugError} from '../../../public/node/error.js' -import {uniq} from '../../../public/common/array.js' /** * Generate a flat array with all the default scopes for all the APIs plus @@ -9,9 +8,9 @@ import {uniq} from '../../../public/common/array.js' * @returns Array of scopes */ export function allDefaultScopes(extraScopes: string[] = []): string[] { - const scopes = allAPIs.flatMap((api) => defaultApiScopes(api)) - const allScopes = ['openid', ...scopes, ...extraScopes].map(scopeTransform) - return uniq(allScopes) + let scopes = allAPIs.map((api) => defaultApiScopes(api)).flat() + scopes = ['openid', ...scopes, ...extraScopes].map(scopeTransform) + return Array.from(new Set(scopes)) } /** @@ -22,8 +21,8 @@ export function allDefaultScopes(extraScopes: string[] = []): string[] { * @returns Array of scopes */ export function apiScopes(api: API, extraScopes: string[] = []): string[] { - const scopes = [...defaultApiScopes(api), ...extraScopes].map(scopeTransform) - return uniq(scopes) + const scopes = [...defaultApiScopes(api), ...extraScopes.map(scopeTransform)].map(scopeTransform) + return Array.from(new Set(scopes)) } /** diff --git a/packages/cli-kit/src/public/common/array.test.ts b/packages/cli-kit/src/public/common/array.test.ts index 2863f5dc8a0..720becbac0b 100644 --- a/packages/cli-kit/src/public/common/array.test.ts +++ b/packages/cli-kit/src/public/common/array.test.ts @@ -1,12 +1,4 @@ -import { - asHumanFriendlyArray, - difference, - getArrayContainsDuplicates, - getArrayRejectingUndefined, - takeRandomFromArray, - uniq, - uniqBy, -} from './array.js' +import {difference, uniq, uniqBy} from './array.js' import {describe, test, expect} from 'vitest' describe('uniqBy', () => { @@ -70,121 +62,3 @@ describe('difference', () => { expect(got).toEqual([1]) }) }) - -describe('takeRandomFromArray', () => { - test('returns a random element from the array', () => { - // Given - const array = [1, 2, 3, 4, 5] - - // When - const got = takeRandomFromArray(array) - - // Then - expect(array).toContain(got) - }) -}) - -describe('getArrayRejectingUndefined', () => { - test('removes undefined elements', () => { - // Given - const array = [1, undefined, 2, undefined, 3] - - // When - const got = getArrayRejectingUndefined(array) - - // Then - expect(got).toEqual([1, 2, 3]) - }) - - test('keeps other falsy values', () => { - // Given - const array = [0, '', null, false, undefined] as any[] - - // When - const got = getArrayRejectingUndefined(array) - - // Then - expect(got).toEqual([0, '', null, false]) - }) -}) - -describe('getArrayContainsDuplicates', () => { - test('returns true if there are duplicates', () => { - // Given - const array = [1, 2, 2, 3] - - // When - const got = getArrayContainsDuplicates(array) - - // Then - expect(got).toBe(true) - }) - - test('returns false if there are no duplicates', () => { - // Given - const array = [1, 2, 3] - - // When - const got = getArrayContainsDuplicates(array) - - // Then - expect(got).toBe(false) - }) -}) - -describe('asHumanFriendlyArray', () => { - test('returns empty array if input is empty', () => { - // Given - const array: string[] = [] - - // When - const got = asHumanFriendlyArray(array) - - // Then - expect(got).toEqual([]) - }) - - test('returns single item array if input has one item', () => { - // Given - const array = ['apple'] - - // When - const got = asHumanFriendlyArray(array) - - // Then - expect(got).toEqual(['apple']) - }) - - test('returns two items separated by and', () => { - // Given - const array = ['apple', 'banana'] - - // When - const got = asHumanFriendlyArray(array) - - // Then - expect(got).toEqual(['apple', 'and', 'banana']) - }) - - test('returns multiple items separated by commas and and', () => { - // Given - const array = ['apple', 'banana', 'cherry'] - - // When - const got = asHumanFriendlyArray(array) - - // Then - expect(got).toEqual(['apple', ', ', 'banana', 'and', 'cherry']) - }) - - test('handles more than three items', () => { - // Given - const array = ['apple', 'banana', 'cherry', 'date'] - - // When - const got = asHumanFriendlyArray(array) - - // Then - expect(got).toEqual(['apple', ', ', 'banana', ', ', 'cherry', 'and', 'date']) - }) -}) diff --git a/packages/cli-kit/src/public/common/object.test.ts b/packages/cli-kit/src/public/common/object.test.ts index 00dc883cbbf..93a116639c0 100644 --- a/packages/cli-kit/src/public/common/object.test.ts +++ b/packages/cli-kit/src/public/common/object.test.ts @@ -1,7 +1,6 @@ import { compact, deepCompare, - deepCompareWithOrderInsensitiveArrays, deepDifference, deepMergeObjects, getPathValue, @@ -117,40 +116,6 @@ describe('deepCompare', () => { }) }) -describe('deepCompareWithOrderInsensitiveArrays', () => { - test('returns true when arrays contain the same values in a different order', () => { - const first = { - webhooks: { - subscriptions: [ - {topic: 'orders/delete', uri: 'https://example.com/orders/delete'}, - {topic: 'products/update', uri: 'https://example.com/products/update'}, - ], - }, - } - const second = { - webhooks: { - subscriptions: [ - {uri: 'https://example.com/products/update', topic: 'products/update'}, - {uri: 'https://example.com/orders/delete', topic: 'orders/delete'}, - ], - }, - } - - const result = deepCompareWithOrderInsensitiveArrays(first, second) - - expect(result).toBeTruthy() - }) - - test('returns false when arrays contain different values', () => { - const first = {items: [{id: 1}, {id: 2}]} - const second = {items: [{id: 1}, {id: 3}]} - - const result = deepCompareWithOrderInsensitiveArrays(first, second) - - expect(result).toBeFalsy() - }) -}) - describe('deepDifference', () => { test('returns the difference between two objects', () => { // Given diff --git a/packages/cli-kit/src/public/common/object.ts b/packages/cli-kit/src/public/common/object.ts index 0d6a02f2a8f..23e6e122df8 100644 --- a/packages/cli-kit/src/public/common/object.ts +++ b/packages/cli-kit/src/public/common/object.ts @@ -68,37 +68,6 @@ export function deepCompare(one: object, two: object): boolean { return lodashIsEqual(one, two) } -/** - * Deeply compares two values and treats arrays as order-insensitive. - * - * @param one - The first value to be compared. - * @param two - The second value to be compared. - * @returns True if the normalized values are equal, false otherwise. - */ -export function deepCompareWithOrderInsensitiveArrays(one: unknown, two: unknown): boolean { - return lodashIsEqual(normalizeOrderInsensitiveArrays(one), normalizeOrderInsensitiveArrays(two)) -} - -function normalizeOrderInsensitiveArrays(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalizeOrderInsensitiveArrays).sort(compareNormalizedValues) - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .map(([key, nestedValue]) => [key, normalizeOrderInsensitiveArrays(nestedValue)]), - ) - } - - return value ?? {} -} - -function compareNormalizedValues(left: unknown, right: unknown) { - return JSON.stringify(left).localeCompare(JSON.stringify(right)) -} - /** * Return the difference between two nested objects. * diff --git a/packages/cli-kit/src/public/common/version.ts b/packages/cli-kit/src/public/common/version.ts index fc68ae42d33..d5ca40d0cdd 100644 --- a/packages/cli-kit/src/public/common/version.ts +++ b/packages/cli-kit/src/public/common/version.ts @@ -1 +1 @@ -export const CLI_KIT_VERSION = '4.5.0' +export const CLI_KIT_VERSION = '4.4.0' diff --git a/packages/cli-kit/src/public/node/analytics.test.ts b/packages/cli-kit/src/public/node/analytics.test.ts index 97c5d95d202..975de8b6e77 100644 --- a/packages/cli-kit/src/public/node/analytics.test.ts +++ b/packages/cli-kit/src/public/node/analytics.test.ts @@ -33,14 +33,6 @@ vi.mock('./monorail.js') vi.mock('./cli.js') vi.mock('./error-handler.js') -function restoreEnvVariable(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } -} - describe('event tracking', () => { const currentDate = new Date(Date.UTC(2022, 1, 1, 10, 0, 0)) let publishEventMock: MockedFunction @@ -210,7 +202,7 @@ describe('event tracking', () => { await inProjectWithFile('package.json', async (args) => { // Given const commandContent = {command: 'dev', topic: 'app'} - const argsWithPassword = args.concat(['--password', 'shptka_abc123', '--store-password', 'store-secret']) + const argsWithPassword = args.concat(['--password', 'shptka_abc123']) await startAnalytics({commandContent, args: argsWithPassword, currentTime: currentDate.getTime() - 100}) // When @@ -222,7 +214,7 @@ describe('event tracking', () => { // Then const expectedPayloadSensitive = { - args: expect.stringMatching(/.*password \*\*\*\*\*.*store-password \*\*\*\*\*/), + args: expect.stringMatching(/.*password \*\*\*\*\*/), metadata: expect.anything(), } expect(publishEventMock).toHaveBeenCalledOnce() @@ -230,67 +222,34 @@ describe('event tracking', () => { }) }) - test('does not send store password environment flags to Monorail', async () => { + test('sends SHOPIFY_ environment variables in sensitive payload', async () => { + const originalEnv = {...process.env} + process.env.SHOPIFY_TEST_VAR = 'test_value' + process.env.SHOPIFY_ANOTHER_VAR = 'another_value' + process.env.NOT_SHOPIFY_VAR = 'should_not_appear' + await inProjectWithFile('package.json', async (args) => { const commandContent = {command: 'dev', topic: 'app'} await startAnalytics({commandContent, args, currentTime: currentDate.getTime() - 100}) - await addSensitiveMetadata(() => ({ - environmentFlags: JSON.stringify({'store-password': 'store-secret'}), - })) + // When const config = { runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), plugins: [], } as any await reportAnalyticsEvent({config, exitMode: 'ok'}) + // Then + const sensitivePayload = publishEventMock.mock.calls[0]![2] expect(publishEventMock).toHaveBeenCalledOnce() - expect(publishEventMock.mock.calls[0]![2]).toMatchObject({ - cmd_all_environment_flags: JSON.stringify({'store-password': '*****'}), - }) - }) - }) - - test('sends SHOPIFY_ environment variables in sensitive payload', async () => { - const originalShopifyTestVar = process.env.SHOPIFY_TEST_VAR - const originalShopifyAnotherVar = process.env.SHOPIFY_ANOTHER_VAR - const originalShopifyFlagStorePassword = process.env.SHOPIFY_FLAG_STORE_PASSWORD - const originalNotShopifyVar = process.env.NOT_SHOPIFY_VAR - process.env.SHOPIFY_TEST_VAR = 'test_value' - process.env.SHOPIFY_ANOTHER_VAR = 'another_value' - process.env.SHOPIFY_FLAG_STORE_PASSWORD = 'store-secret' - process.env.NOT_SHOPIFY_VAR = 'should_not_appear' + expect(sensitivePayload).toHaveProperty('env_shopify_variables') + expect(sensitivePayload.env_shopify_variables).toBeDefined() - try { - await inProjectWithFile('package.json', async (args) => { - const commandContent = {command: 'dev', topic: 'app'} - await startAnalytics({commandContent, args, currentTime: currentDate.getTime() - 100}) - - // When - const config = { - runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), - plugins: [], - } as any - await reportAnalyticsEvent({config, exitMode: 'ok'}) - - // Then - const sensitivePayload = publishEventMock.mock.calls[0]![2] - expect(publishEventMock).toHaveBeenCalledOnce() - expect(sensitivePayload).toHaveProperty('env_shopify_variables') - expect(sensitivePayload.env_shopify_variables).toBeDefined() - - const shopifyVars = JSON.parse(sensitivePayload.env_shopify_variables as string) - expect(shopifyVars).toHaveProperty('SHOPIFY_TEST_VAR', 'test_value') - expect(shopifyVars).toHaveProperty('SHOPIFY_ANOTHER_VAR', 'another_value') - expect(shopifyVars).toHaveProperty('SHOPIFY_FLAG_STORE_PASSWORD', '*****') - expect(shopifyVars).not.toHaveProperty('NOT_SHOPIFY_VAR') - }) - } finally { - restoreEnvVariable('SHOPIFY_TEST_VAR', originalShopifyTestVar) - restoreEnvVariable('SHOPIFY_ANOTHER_VAR', originalShopifyAnotherVar) - restoreEnvVariable('SHOPIFY_FLAG_STORE_PASSWORD', originalShopifyFlagStorePassword) - restoreEnvVariable('NOT_SHOPIFY_VAR', originalNotShopifyVar) - } + const shopifyVars = JSON.parse(sensitivePayload.env_shopify_variables as string) + expect(shopifyVars).toHaveProperty('SHOPIFY_TEST_VAR', 'test_value') + expect(shopifyVars).toHaveProperty('SHOPIFY_ANOTHER_VAR', 'another_value') + expect(shopifyVars).not.toHaveProperty('NOT_SHOPIFY_VAR') + }) }) test('does nothing when analytics are disabled', async () => { diff --git a/packages/cli-kit/src/public/node/analytics.ts b/packages/cli-kit/src/public/node/analytics.ts index dcaef13d51c..3ee7f2c466d 100644 --- a/packages/cli-kit/src/public/node/analytics.ts +++ b/packages/cli-kit/src/public/node/analytics.ts @@ -198,10 +198,7 @@ async function buildPayload({config, errorMessage, exitMode}: ReportAnalyticsEve function sanitizePayload(payload: T): T { const payloadString = JSON.stringify(payload) // Remove Theme Access passwords from the payload - const sanitizedPayloadString = payloadString - .replace(/shptka_\w*/g, '*****') - .replace(/(--store-password(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s"]+)/g, '$1*****') - .replace(/((?:store-password|SHOPIFY_FLAG_STORE_PASSWORD)\\?":\\?")[^"\\]*/g, '$1*****') + const sanitizedPayloadString = payloadString.replace(/shptka_\w*/g, '*****') return JSON.parse(sanitizedPayloadString) } diff --git a/packages/cli-kit/src/public/node/error-handler.test.ts b/packages/cli-kit/src/public/node/error-handler.test.ts index 6a6e5a1a14e..261894a1577 100644 --- a/packages/cli-kit/src/public/node/error-handler.test.ts +++ b/packages/cli-kit/src/public/node/error-handler.test.ts @@ -88,20 +88,6 @@ describe('errorHandler', async () => { expect(outputMock.info()).toMatch('✨ Custom message') expect(process.exit).toBeCalledTimes(0) }) - - test('finishes the execution without exiting the proccess when AbortSilentError is raised', async () => { - // Given - vi.spyOn(process, 'exit').mockResolvedValue(null as never) - const outputMock = mockAndCaptureOutput() - outputMock.clear() - - // When - await errorHandler(new error.AbortSilentError()) - - // Then - expect(outputMock.info()).toBe('') - expect(process.exit).toBeCalledTimes(0) - }) }) describe('bugsnag stack cleaning', () => { diff --git a/packages/cli-kit/src/public/node/error-handler.ts b/packages/cli-kit/src/public/node/error-handler.ts index 706249ed1d4..7e6617c11e5 100644 --- a/packages/cli-kit/src/public/node/error-handler.ts +++ b/packages/cli-kit/src/public/node/error-handler.ts @@ -45,16 +45,17 @@ export async function errorHandler( if (error.message && error.message !== '') { outputInfo(`✨ ${error.message}`) } - return - } - - if (error instanceof AbortSilentError) { - return + } else if (error instanceof AbortSilentError) { + /* empty */ + } else { + return errorMapper(error) + .then((error) => { + return handler(error) + }) + .then((mappedError) => { + return reportError(mappedError, config) + }) } - - const mappedError = await errorMapper(error) - await handler(mappedError) - await reportError(mappedError, config) } const reportError = async (error: unknown, config?: Interfaces.Config): Promise => { diff --git a/packages/cli-kit/src/public/node/graphiql/server.ts b/packages/cli-kit/src/public/node/graphiql/server.ts index b82380f40ea..cffeebcb819 100644 --- a/packages/cli-kit/src/public/node/graphiql/server.ts +++ b/packages/cli-kit/src/public/node/graphiql/server.ts @@ -155,9 +155,9 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server ) } - const faviconPath = require.resolve('../../../../assets/graphiql/favicon.ico') + const faviconPath = require.resolve('@shopify/cli-kit/assets/graphiql/favicon.ico') const faviconContent = readFileSync(faviconPath) - const stylePath = require.resolve('../../../../assets/graphiql/style.css') + const stylePath = require.resolve('@shopify/cli-kit/assets/graphiql/style.css') const styleContent = readFileSync(stylePath, 'utf8') app.use( diff --git a/packages/cli-kit/src/public/node/hrtime.test.ts b/packages/cli-kit/src/public/node/hrtime.test.ts index aebb6b4501b..009a20aa3ab 100644 --- a/packages/cli-kit/src/public/node/hrtime.test.ts +++ b/packages/cli-kit/src/public/node/hrtime.test.ts @@ -1,7 +1,13 @@ import {startHRTime, endHRTimeInMs} from './hrtime.js' -import {describe, test, expect, vi} from 'vitest' +import {describe, test, expect, vi, afterEach} from 'vitest' describe('hrtime', () => { + afterEach(() => { + // In theory this shouldn't be necessary, but vitest doesn't restore spies automatically on some platforms. + // eslint-disable-next-line @shopify/cli/no-vi-manual-mock-clear + vi.restoreAllMocks() + }) + test('startHRTime returns the current high-resolution real time', () => { // Given const mockTime: [number, number] = [123, 456] diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index 33cfe864658..c2335b46d9d 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -95,8 +95,16 @@ export class FindUpAndReadPackageJsonNotFoundError extends BugError { * @returns The dependency manager */ export function packageManagerFromUserAgent(env = process.env): PackageManager { - const userAgent = env.npm_config_user_agent - return (['yarn', 'pnpm', 'bun', 'npm'] as const).find((pm) => userAgent?.includes(pm)) ?? 'unknown' + if (env.npm_config_user_agent?.includes('yarn')) { + return 'yarn' + } else if (env.npm_config_user_agent?.includes('pnpm')) { + return 'pnpm' + } else if (env.npm_config_user_agent?.includes('bun')) { + return 'bun' + } else if (env.npm_config_user_agent?.includes('npm')) { + return 'npm' + } + return 'unknown' } function hasBunLockfileSync(directory: string): boolean { diff --git a/packages/cli-kit/src/public/node/promises.test.ts b/packages/cli-kit/src/public/node/promises.test.ts deleted file mode 100644 index ee736d7f02f..00000000000 --- a/packages/cli-kit/src/public/node/promises.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {flushPromises} from './promises.js' -import {describe, expect, test} from 'vitest' - -describe('flushPromises', () => { - test('waits for microtasks to complete', async () => { - // Given - let result = '' - const _promise = Promise.resolve().then(() => { - result += 'microtask' - }) - - // When - await flushPromises() - result += ' flushed' - - // Then - expect(result).toBe('microtask flushed') - }) - - test('waits for multiple nested microtasks', async () => { - // Given - let result = '' - const _promise = Promise.resolve() - .then(() => { - result += '1' - return 'intermediate' - }) - .then(() => { - result += '2' - }) - - // When - await flushPromises() - result += ' flushed' - - // Then - expect(result).toBe('12 flushed') - }) - - test('waits for macrotasks scheduled with setImmediate', async () => { - // Given - let result = '' - setImmediate(() => { - result += 'macrotask' - }) - - // When - await flushPromises() - result += ' flushed' - - // Then - expect(result).toBe('macrotask flushed') - }) -}) diff --git a/packages/cli-kit/src/public/node/system.test.ts b/packages/cli-kit/src/public/node/system.test.ts index 6c863f60dd4..ed12516ba40 100644 --- a/packages/cli-kit/src/public/node/system.test.ts +++ b/packages/cli-kit/src/public/node/system.test.ts @@ -393,13 +393,3 @@ describe('readStdinString', () => { await expect(got).rejects.toThrow('Stdin input exceeded the maximum allowed size.') }) }) - -describe('sleep', () => { - test('waits for the specified number of seconds', async () => { - vi.useFakeTimers() - const sleepPromise = system.sleep(1) - await vi.advanceTimersByTimeAsync(1000) - await expect(sleepPromise).resolves.toBeUndefined() - vi.useRealTimers() - }) -}) diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts index aa997f8c5d8..3f449f8ff34 100644 --- a/packages/cli-kit/src/public/node/system.ts +++ b/packages/cli-kit/src/public/node/system.ts @@ -13,7 +13,6 @@ import which from 'which' import {delimiter} from 'pathe' import {fstatSync} from 'fs' -import {setTimeout} from 'timers/promises' import type {Writable, Readable} from 'stream' /** @@ -320,7 +319,9 @@ function checkCommandSafety(command: string, _options: {cwd: string}): void { * @returns A Promise resolving after the number of seconds. */ export async function sleep(seconds: number): Promise { - await setTimeout(1000 * seconds) + return new Promise((resolve) => { + setTimeout(resolve, 1000 * seconds) + }) } /** diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 495bd1c4519..39ddba89292 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,14 +1,5 @@ # @shopify/cli -## 4.5.0 - -### Minor Changes - -- bacbcf2: Add `shopify store auth list` to list stores authenticated on this machine with `shopify store auth`. -- bacbcf2: Add `shopify store create preview` to create a preview Shopify store with no existing account required. -- bacbcf2: Add `shopify store list` to list stores in a Shopify organization. -- bacbcf2: Add `shopify store open` to open a store's storefront in your default web browser. - ## 4.4.0 ### Minor Changes diff --git a/packages/cli/README.md b/packages/cli/README.md index 0ac9c268133..9863affaf6e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,16 +80,12 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) -* [`shopify store auth list`](#shopify-store-auth-list) * [`shopify store bulk cancel`](#shopify-store-bulk-cancel) * [`shopify store bulk execute`](#shopify-store-bulk-execute) * [`shopify store bulk status`](#shopify-store-bulk-status) -* [`shopify store create preview`](#shopify-store-create-preview) * [`shopify store execute`](#shopify-store-execute) * [`shopify store graphiql`](#shopify-store-graphiql) * [`shopify store info`](#shopify-store-info) -* [`shopify store list`](#shopify-store-list) -* [`shopify store open`](#shopify-store-open) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -653,10 +649,9 @@ Run the app. ``` USAGE $ shopify app dev [--auth-alias ] [--checkout-cart-url ] [--client-id | -c ] - [--install-mkcert --use-localhost] [--localhost-port ] [--no-color] [--no-update] [--notify ] [--path - ] [--reset | ] [--skip-dependencies-installation] [-s ] [--store-password ] - [--subscription-product-url ] [-t ] [--theme-app-extension-port ] [--tunnel-url | ] - [--verbose] + [--localhost-port ] [--no-color] [--no-update] [--notify ] [--path ] [--reset | ] + [--skip-dependencies-installation] [-s ] [--subscription-product-url ] [-t ] + [--theme-app-extension-port ] [--use-localhost | [--tunnel-url | ]] [--verbose] FLAGS -c, --config= @@ -683,10 +678,6 @@ FLAGS The Client ID of your app. [env: SHOPIFY_FLAG_CLIENT_ID] - --install-mkcert - Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting. - [env: SHOPIFY_FLAG_INSTALL_MKCERT] - --localhost-port= Port to use for localhost. Must be between 1 and 65535. [env: SHOPIFY_FLAG_LOCALHOST_PORT] @@ -716,10 +707,6 @@ FLAGS Skips the installation of dependencies. Deprecated, use workspaces instead. [env: SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION] - --store-password= - The password for storefronts with password protection. - [env: SHOPIFY_FLAG_STORE_PASSWORD] - --subscription-product-url= Resource URL for subscription UI extension. Format: "/products/{productId}" [env: SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL] @@ -3416,41 +3403,6 @@ EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` -## `shopify store auth list` - -List stores authenticated directly with store auth. - -``` -USAGE - $ shopify store auth list [-j] [--no-color] [--verbose] - -FLAGS - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --verbose - Increase the verbosity of the output. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - List stores authenticated directly with store auth. - - Lists stores authenticated directly on this machine with `shopify store auth`. - - Use this command to find stores that can be used with store-authenticated commands such as `shopify store execute`. - To list stores in a Shopify organization, run `shopify store list`. - -EXAMPLES - $ shopify store auth list - - $ shopify store auth list --json -``` - ## `shopify store bulk cancel` Cancel a bulk operation on a store. @@ -3611,48 +3563,6 @@ EXAMPLES $ shopify store bulk status --store shop.myshopify.com --id 123456789 ``` -## `shopify store create preview` - -Create a preview Shopify store. - -``` -USAGE - $ shopify store create preview [--country ] [-j] [--name ] [--no-color] [--verbose] - -FLAGS - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - --country= - Two-letter country code for the store, such as US, CA, or GB. - [env: SHOPIFY_FLAG_STORE_COUNTRY] - - --name= - The name of the store. - [env: SHOPIFY_FLAG_PREVIEW_STORE_NAME] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --verbose - Increase the verbosity of the output. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Create a preview Shopify store. - - Creates a new Shopify store, with no need for an existing account. - -EXAMPLES - $ shopify store create preview --name "Lavender Candles" - - $ shopify store create preview --name "Lavender Candles" --country US - - $ shopify store create preview --name "Lavender Candles" --json -``` - ## `shopify store execute` Execute GraphQL queries and mutations on a store. @@ -3822,80 +3732,6 @@ EXAMPLES $ shopify store info --store shop.myshopify.com --json ``` -## `shopify store list` - -List stores in a Shopify organization. - -``` -USAGE - $ shopify store list [-j] [--no-color] [--organization-id ] [--verbose] - -FLAGS - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --organization-id= - The numeric organization ID. Auto-selects if you belong to a single organization. - [env: SHOPIFY_FLAG_ORGANIZATION_ID] - - --verbose - Increase the verbosity of the output. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - List stores in a Shopify organization. - - Lists stores in a Shopify organization available to the current CLI account. - - When more than one organization is available, the command prompts you to pick one unless you provide - `--organization-id`. - In non-interactive environments, `--organization-id` is required. - - Run `shopify organization list` to find organization IDs. - -EXAMPLES - $ shopify store list - - $ shopify store list --organization-id 1234567 - - $ shopify store list --json -``` - -## `shopify store open` - -Open your Shopify store in the default web browser. - -``` -USAGE - $ shopify store open -s [--no-color] [--verbose] - -FLAGS - -s, --store= - (required) The myshopify.com domain of the store. - [env: SHOPIFY_FLAG_STORE] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --verbose - Increase the verbosity of the output. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Open your Shopify store in the default web browser. - - Opens the storefront for a store you have access to in your default web browser. - -EXAMPLES - $ shopify store open --store shop.myshopify.com -``` - ## `shopify theme check` Validate the theme. @@ -4103,9 +3939,8 @@ Uploads the current theme as a development theme to the connected store, then pr USAGE $ shopify theme dev [-a] [--auth-alias ] [-e ...] [--error-overlay silent|default] [--host ] [-x ...] [--listing ] [--live-reload hot-reload|full-page|off] [--no-color] [-n] [--notify - ] [-o ...] [--open] [--password ] [--path ] [--port ] [--reconciliation-strategy - keep-local|keep-remote|abort --theme-editor-sync] [--standard-events-inspector] [-s ] [--store-password - ] [-t ] [--verbose] + ] [-o ...] [--open] [--password ] [--path ] [--port ] + [--standard-events-inspector] [-s ] [--store-password ] [-t ] [--theme-editor-sync] [--verbose] FLAGS -a, --allow-live @@ -4191,12 +4026,6 @@ FLAGS Local port to serve theme preview from. Must be between 1 and 65535. [env: SHOPIFY_FLAG_PORT] - --reconciliation-strategy=