diff --git a/RELEASE_NOTES/3.93.md b/RELEASE_NOTES/3.93.md new file mode 100644 index 00000000000..2dd9aa4940a --- /dev/null +++ b/RELEASE_NOTES/3.93.md @@ -0,0 +1,19 @@ +## App + +- Add `shopify organization list` command to list Shopify organizations you have access to, supports `--json` flag for structured output [#6956](https://github.com/Shopify/cli/pull/6956) +- Add `shopify store auth` and `shopify store execute` commands. `store auth` stores per-user online auth for a store using PKCE. `store execute` runs Admin API GraphQL against that stored auth; mutations are disabled by default and write operations require `--allow-mutations` [#7122](https://github.com/Shopify/cli/pull/7122) +- Add support for `SHOPIFY_APP_AUTOMATION_TOKEN` env var as a new name for `SHOPIFY_CLI_PARTNERS_TOKEN` [#7057](https://github.com/Shopify/cli/pull/7057) +- Enable non-interactive `app init` via a new `--organization-id` flag and not prompting to link to an existing app if `--name` is provided [#6640](https://github.com/Shopify/cli/pull/6640) +- Deprecation warning for `--force` flag on `app deploy` and `app release`. Use `--allow-updates` for CI/CD environments, or `--allow-updates --allow-deletes` if you also want to allow removals [#7059](https://github.com/Shopify/cli/pull/7059) +- Fix crash when organization is not found in app-management-client [#7012](https://github.com/Shopify/cli/pull/7012) + +## Theme + +- Add `--development-context` flag to `theme push` to specify a unique identifier for a development theme context (e.g., PR number, branch name), useful for CI environments [#6657](https://github.com/Shopify/cli/pull/6657) +- Add support for theme previews using a JSON via `theme preview`. Pass a JSON via `--override` to quickly preview overrides on a live theme. Also adds a `--preview-id` flag to handle in-place updates for previews created from an override JSON [#6890](https://github.com/Shopify/cli/pull/6890) +- Add `--json` flag to `theme preview` to configure a json output [#7043](https://github.com/Shopify/cli/pull/7043) +- Change wording for current development theme in `theme list` from `[yours]` to `[current]` to reflect support for multiple development themes [#6657](https://github.com/Shopify/cli/pull/6657) +- Fix theme editor shortcut tracking fetch requests instead of page navigation [#6924](https://github.com/Shopify/cli/pull/6924) +- Add a 250ms debounce on filewatcher for themes to stop potential file deletes [#6791](https://github.com/Shopify/cli/pull/6791) +- Fix Cart rate limiting issue [#6975](https://github.com/Shopify/cli/pull/6975) +- Fix missing json output for `theme info` when no theme or dev flag is present [#6905](https://github.com/Shopify/cli/pull/6905) diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index e38a6d147e8..66957c80ac8 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface storeauth { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index 3bff68a8f95..39550404db6 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -6,6 +6,12 @@ export interface storeexecute { */ '--allow-mutations'?: '' + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 75162409d11..caac62cb2d3 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5829,6 +5829,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", "syntaxKind": "PropertySignature", @@ -5838,7 +5847,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeauth {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\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}" + "value": "export interface storeauth {\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 * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\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}" } } } @@ -5938,6 +5947,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERSION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -5965,7 +5983,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\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 file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } } } diff --git a/eslint.config.js b/eslint.config.js index dd96a5b50e0..2ef521d66a8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,6 @@ import nxPlugin from '@nx/eslint-plugin' import cliPlugin from '@shopify/eslint-plugin-cli' +import jsdocPlugin from 'eslint-plugin-jsdoc' // Spread the CLI plugin's base config which includes all necessary plugins const config = [ @@ -44,6 +45,9 @@ const config = [ '**/public/node/result.ts', '**/public/node/themes/**/*', ], + plugins: { + jsdoc: jsdocPlugin, + }, settings: { jsdoc: { publicFunctionsOnly: true, diff --git a/package.json b/package.json index cd81b6826d4..fc6802e3a90 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "bugsnag-build-reporter": "^2.0.0", "commander": "^9.4.0", "esbuild": "0.27.4", + "eslint-plugin-jsdoc": "50.7.1", "eslint": "^9.26.0", "execa": "^7.2.0", "fast-glob": "3.3.3", 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 784c89a1031..98fdd12ce00 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -5,7 +5,9 @@ import {testAppLinked} from '../../../models/app/app.test-data.js' import {Project} from '../../../models/project/project.js' import {selectActiveConfig} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' +import metadata from '../../../metadata.js' import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {describe, expect, test, vi} from 'vitest' @@ -14,12 +16,19 @@ vi.mock('../../../services/validate.js') vi.mock('../../../models/project/project.js') vi.mock('../../../models/project/active-config.js') vi.mock('../../../models/project/config-selection.js') +vi.mock('../../../metadata.js', () => ({default: {addPublicMetadata: vi.fn()}})) vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() return {...actual, outputResult: vi.fn()} }) vi.mock('@shopify/cli-kit/node/ui') +async function expectValidationMetadataCalls(...expectedMetadata: Record[]) { + const metadataCalls = vi.mocked(metadata.addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata) + expect(metadataCalls).toHaveLength(expectedMetadata.length) + await expect(Promise.all(metadataCalls.map((getMetadata) => getMetadata()))).resolves.toEqual(expectedMetadata) +} + function mockHealthyProject() { vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) @@ -36,6 +45,7 @@ describe('app config validate command', () => { await Validate.run([], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: false}) + await expectValidationMetadataCalls({cmd_app_validate_json: false}) }) test('calls validateApp with json: true when --json flag is passed', async () => { @@ -47,6 +57,7 @@ describe('app config validate command', () => { await Validate.run(['--json'], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) test('calls validateApp with json: true when -j flag is passed', async () => { @@ -58,6 +69,7 @@ describe('app config validate command', () => { await Validate.run(['-j'], import.meta.url) expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) test('outputs JSON issues when active config has TOML parse errors', async () => { @@ -71,5 +83,88 @@ describe('app config validate command', () => { expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata for config errors in non-json mode', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) + vi.mocked(errorsForConfig).mockReturnValue([ + {path: '/app/shopify.app.toml', message: 'Missing required field'} as any, + {path: '/app/shopify.app.toml', message: 'Invalid value'} as any, + ]) + + await expect(Validate.run([], import.meta.url)).rejects.toThrow() + + expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: false}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when Project.load fails with --json', async () => { + vi.mocked(Project.load).mockRejectedValue(new AbortError('Could not find app configuration')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + expect(selectActiveConfig).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when selectActiveConfig fails with --json', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockRejectedValue(new AbortError('No config found')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + expect(linkedAppContext).not.toHaveBeenCalled() + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) + }) + + test('records failure metadata when linkedAppContext throws a validation error with --json', async () => { + vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) + vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) + vi.mocked(errorsForConfig).mockReturnValue([]) + vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Validation errors in /app/shopify.app.toml')) + + await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow() + + expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false')) + await expectValidationMetadataCalls( + {cmd_app_validate_json: true}, + { + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }, + ) }) }) diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index dd9efc4958a..4da9ddc48ba 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -5,11 +5,20 @@ import {linkedAppContext} from '../../../services/app-context.js' import {selectActiveConfig} from '../../../models/project/active-config.js' import {errorsForConfig} from '../../../models/project/config-selection.js' import {Project} from '../../../models/project/project.js' +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 {renderError} from '@shopify/cli-kit/node/ui' +async function recordValidationFailure(issueCount: number, fileCount: number) { + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: issueCount, + cmd_app_validate_file_count: fileCount, + })) +} + export default class Validate extends AppLinkedCommand { static summary = 'Validate your app configuration and extensions.' @@ -26,12 +35,17 @@ export default class Validate extends AppLinkedCommand { public async run(): Promise { const {flags} = await this.parse(Validate) + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_json: flags.json, + })) + // Stage 1: Load project let project: Project try { project = await Project.load(flags.path) } catch (err) { if (err instanceof AbortError && flags.json) { + await recordValidationFailure(1, 1) const message = unstyled(stringifyMessage(err.message)).trim() outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() @@ -45,6 +59,7 @@ export default class Validate extends AppLinkedCommand { activeConfig = await selectActiveConfig(project, flags.config) } catch (err) { if (err instanceof AbortError && flags.json) { + await recordValidationFailure(1, 1) const message = unstyled(stringifyMessage(err.message)).trim() outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() @@ -55,6 +70,8 @@ export default class Validate extends AppLinkedCommand { const configErrors = errorsForConfig(project, activeConfig.file) if (configErrors.length > 0) { const issues = configErrors.map((err) => ({file: err.path, message: err.message})) + const fileCount = new Set(configErrors.map((err) => err.path)).size + await recordValidationFailure(issues.length, fileCount) if (flags.json) { outputResult(JSON.stringify({valid: false, issues}, null, 2)) throw new AbortSilentError() @@ -83,6 +100,7 @@ export default class Validate extends AppLinkedCommand { const message = err instanceof AbortError ? unstyled(stringifyMessage(err.message)).trim() : '' const isValidationError = message.startsWith('Validation errors in ') if (isValidationError && flags.json) { + await recordValidationFailure(1, 1) outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2)) throw new AbortSilentError() } diff --git a/packages/app/src/cli/models/extensions/specification.integration.test.ts b/packages/app/src/cli/models/extensions/specification.integration.test.ts index 9bbcdd6167c..11f7912698d 100644 --- a/packages/app/src/cli/models/extensions/specification.integration.test.ts +++ b/packages/app/src/cli/models/extensions/specification.integration.test.ts @@ -1,5 +1,12 @@ import {loadLocalExtensionsSpecifications} from './load-specifications.js' -import {configWithoutFirstClassFields, createContractBasedModuleSpecification} from './specification.js' +import { + configWithoutFirstClassFields, + createContractBasedModuleSpecification, + createConfigExtensionSpecification, + createExtensionSpecification, +} from './specification.js' +import {BaseSchema} from './schemas.js' +import {ClientSteps} from '../../services/build/client-steps.js' import {AppSchema} from '../app/app.js' import {describe, test, expect, beforeAll} from 'vitest' @@ -28,6 +35,19 @@ describe('allLocalSpecs', () => { }) }) +const testClientSteps: ClientSteps = [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'copy_static', + name: 'Copy static assets', + type: 'copy_static_assets', + }, + ], + }, +] + describe('createContractBasedModuleSpecification', () => { test('creates a specification with the given identifier', () => { // When @@ -48,6 +68,62 @@ describe('createContractBasedModuleSpecification', () => { ) expect(got.appModuleFeatures()).toEqual(['localization']) }) + + test('passes clientSteps through to the created specification', () => { + // When + const got = createContractBasedModuleSpecification({ + identifier: 'channel_config', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) + + test('clientSteps is undefined when not provided', () => { + // When + const got = createContractBasedModuleSpecification({ + identifier: 'test', + uidStrategy: 'uuid', + experience: 'extension', + appModuleFeatures: () => [], + }) + + // Then + expect(got.clientSteps).toBeUndefined() + }) +}) + +describe('createExtensionSpecification', () => { + test('passes clientSteps through to the created specification', () => { + // When + const got = createExtensionSpecification({ + identifier: 'test_extension', + appModuleFeatures: () => [], + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) +}) + +describe('createConfigExtensionSpecification', () => { + test('passes clientSteps through to the created specification', () => { + // When + const got = createConfigExtensionSpecification({ + identifier: 'test_config', + schema: BaseSchema, + transformConfig: {}, + clientSteps: testClientSteps, + }) + + // Then + expect(got.clientSteps).toEqual(testClientSteps) + }) }) describe('configWithoutFirstClassFields', () => { diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f7b1bea33c..3df81f77f7f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -294,11 +294,12 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ identifier: spec.identifier, - schema: zod.any({}) as unknown as ZodSchemaType, + schema: spec.schema ?? (zod.any({}) as unknown as ZodSchemaType), appModuleFeatures: spec.appModuleFeatures, experience: spec.experience, buildConfig: spec.buildConfig ?? {mode: 'none'}, diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 0aea2ee661a..e4c859fbbbe 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -1,8 +1,20 @@ import {createContractBasedModuleSpecification} from '../specification.js' +import {ZodSchemaType, BaseConfigType} from '../schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const AdminSchema = zod.object({ + admin: zod + .object({ + static_root: zod.string().optional(), + }) + .optional(), +}) const adminSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'admin', uidStrategy: 'single', + experience: 'configuration', + schema: AdminSchema as unknown as ZodSchemaType, transformRemoteToLocal: (remoteContent) => { return { admin: { @@ -24,7 +36,8 @@ const adminSpecificationSpec = createContractBasedModuleSpecification({ name: 'Hosted App Copy Files', type: 'include_assets', config: { - generatesAssetsManifest: true, + // Remove this until we fix the bug related to recreating the manifest during dev + generatesAssetsManifest: false, inclusions: [ { type: 'configKey', diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts index e54a27ba937..fafd5d3ed21 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts @@ -32,6 +32,7 @@ export async function handleWatcherEvents( const affectedExtensions = app.realExtensions.filter((ext) => ext.directory === event.extensionPath) const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options}) appEvent.extensionEvents.push(...newEvent.extensionEvents) + if (newEvent.appAssetsUpdated) appEvent.appAssetsUpdated = true } return appEvent @@ -61,6 +62,7 @@ const handlers: {[key in WatcherEvent['type']]: Handler} = { file_deleted: FileChangeHandler, file_updated: FileChangeHandler, app_config_deleted: AppConfigDeletedHandler, + app_asset_updated: AppAssetUpdatedHandler, // These two are processed manually to avoid multiple reloads extension_folder_created: EmptyHandler, extensions_config_updated: EmptyHandler, @@ -91,6 +93,17 @@ function FileChangeHandler({event, app, extensions}: HandlerInput): AppEvent { return {app, extensionEvents: events, startTime: event.startTime, path: event.path} } +/** + * When a file inside an app asset directory (e.g. static_root) is updated: + * Find the owning extension via extensionPath and return an Updated event so it gets rebuilt. + * The rebuild runs the include_assets step which copies the changed files into the bundle. + */ +function AppAssetUpdatedHandler({event, app}: HandlerInput): AppEvent { + const adminExtension = app.realExtensions.find((ext) => ext.specification.identifier === 'admin') + const events: ExtensionEvent[] = adminExtension ? [{type: EventType.Updated, extension: adminExtension}] : [] + return {app, extensionEvents: events, startTime: event.startTime, path: event.path, appAssetsUpdated: true} +} + /** * When an event doesn't require any action, return the same app and an empty event. */ diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 5a760de6332..2436bfc9448 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -82,6 +82,7 @@ export interface AppEvent { path: string startTime: [number, number] appWasReloaded?: boolean + appAssetsUpdated?: boolean } type ExtensionBuildResult = {status: 'ok'; uid: string} | {status: 'error'; error: string; file?: string; uid: string} 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 ca479009e0a..aed4668a773 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 @@ -263,15 +263,15 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { - ignored: [ + ignored: expect.arrayContaining([ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', - ], + expect.any(Function), + ]), ignoreInitial: true, persistent: true, }) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index fae4b59f190..8d273bf3b6e 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -1,6 +1,7 @@ /* eslint-disable no-case-declarations */ import {AppLinkedInterface} from '../../../models/app/app.js' import {configurationFileNames} from '../../../constants.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {dirname, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path' import {FSWatcher} from 'chokidar' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -9,6 +10,7 @@ import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime' import {fileExistsSync, matchGlob, mkdir, readFileSync} from '@shopify/cli-kit/node/fs' import {debounce} from '@shopify/cli-kit/common/function' import ignore from 'ignore' +import {getPathValue} from '@shopify/cli-kit/common/object' import {Writable} from 'stream' const DEFAULT_DEBOUNCE_TIME_IN_MS = 200 @@ -36,6 +38,7 @@ export interface WatcherEvent { | 'file_deleted' | 'extensions_config_updated' | 'app_config_deleted' + | 'app_asset_updated' path: string extensionPath: string startTime: StartTime @@ -58,6 +61,8 @@ export class FileWatcher { private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {} // Map of file paths to the extensions that watch them private readonly extensionWatchedFiles = new Map>() + // Map of asset directory path to the extension directory that owns it + private appAssetToExtensionDir = new Map() constructor( app: AppLinkedInterface, @@ -104,7 +109,9 @@ export class FileWatcher { }), ) + this.appAssetToExtensionDir = this.resolveAppAssetWatchPaths(this.app.realExtensions) const watchPaths = [this.app.configPath, ...fullExtensionDirectories] + Array.from(this.appAssetToExtensionDir.keys()).forEach((key) => watchPaths.push(key)) // Get all watched files from extensions const allWatchedFiles = this.getAllWatchedFiles() @@ -114,15 +121,24 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') + const appAssetDirs = [...this.appAssetToExtensionDir.keys()] this.watcher = chokidar.watch(watchPaths, { ignored: [ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', + // Ignore files inside dist/ directories, unless the path falls under a watched + // app asset directory (e.g. static_root may point to a dist/ folder). + // Non-dist paths are never ignored here (return false). For dist paths, we only + // allow them through if they are inside one of the app asset directories. + (filePath: string) => { + const normalized = normalizePath(filePath) + if (!normalized.includes('/dist/') && !normalized.endsWith('/dist')) return false + return !appAssetDirs.some((assetDir) => normalized.startsWith(assetDir)) + }, ], persistent: true, ignoreInitial: true, @@ -177,6 +193,36 @@ export class FileWatcher { return Array.from(allFiles) } + /** + * Resolves app asset directories that should be watched. + * Returns a map of absolute asset directory path → owning extension directory. + */ + private resolveAppAssetWatchPaths(allExtensions: ExtensionInstance[]): Map { + const result = new Map() + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') + if (staticRootPath) { + const absolutePath = joinPath(adminExtension.directory, staticRootPath) + result.set(normalizePath(absolutePath), normalizePath(adminExtension.directory)) + } + } + return result + } + + /** + * Checks if a file path is inside any app asset directory. + * Returns the owning extension directory if found, undefined otherwise. + */ + private findAppAssetExtensionDir(filePath: string): string | undefined { + for (const [assetDir, extensionDir] of this.appAssetToExtensionDir) { + if (filePath.startsWith(assetDir)) { + return extensionDir + } + } + return undefined + } + /** * Emits the accumulated events and resets the current events list. * It also logs the number of events emitted and their paths for debugging purposes. @@ -227,7 +273,12 @@ export class FileWatcher { * Explicit watch paths have priority over custom gitignore files */ private shouldIgnoreEvent(event: WatcherEvent) { - if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false + if ( + event.type === 'extension_folder_deleted' || + event.type === 'extension_folder_created' || + event.type === 'app_asset_updated' + ) + return false const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath) const watchPaths = extension?.watchedFiles() @@ -258,6 +309,16 @@ export class FileWatcher { const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath) const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0 + // Check if the file is inside an app asset directory (e.g. static_root) + const appAssetExtensionDir = this.findAppAssetExtensionDir(normalizedPath) + if (appAssetExtensionDir) { + if (event === 'change' || event === 'add' || event === 'unlink') { + this.pushEvent({type: 'app_asset_updated', path, extensionPath: appAssetExtensionDir, startTime}) + } + this.debouncedEmit() + return + } + if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) { // Ignore an event if it's not part of an existing extension // Except if it is a toml file (either app config or extension config) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..0acc5c5daf2 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -1,7 +1,7 @@ import * as store from './extension/payload/store.js' import * as server from './extension/server.js' import * as websocket from './extension/websocket.js' -import {devUIExtensions, ExtensionDevOptions} from './extension.js' +import {devUIExtensions, ExtensionDevOptions, resolveAppAssets} from './extension.js' import {ExtensionsEndpointPayload} from './extension/payload/models.js' import {WebsocketConnection} from './extension/websocket/models.js' import {AppEventWatcher} from './app-events/app-event-watcher.js' @@ -33,7 +33,10 @@ describe('devUIExtensions()', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({mock: 'payload-store'})) + vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({ + mock: 'payload-store', + updateAppAssets: vi.fn(), + })) vi.spyOn(server, 'setupHTTPServer').mockReturnValue({ mock: 'http-server', close: serverCloseSpy, @@ -65,11 +68,13 @@ describe('devUIExtensions()', () => { await devUIExtensions(options) // THEN - expect(server.setupHTTPServer).toHaveBeenCalledWith({ - devOptions: {...options, websocketURL: 'wss://mock.url/extensions'}, - payloadStore: {mock: 'payload-store'}, - getExtensions: expect.any(Function), - }) + expect(server.setupHTTPServer).toHaveBeenCalledWith( + expect.objectContaining({ + devOptions: expect.objectContaining({websocketURL: 'wss://mock.url/extensions'}), + payloadStore: expect.objectContaining({mock: 'payload-store'}), + getExtensions: expect.any(Function), + }), + ) }) test('initializes the HTTP server with a getExtensions function that returns the extensions from the provided options', async () => { @@ -91,12 +96,13 @@ describe('devUIExtensions()', () => { await devUIExtensions(options) // THEN - expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({ - ...options, - httpServer: expect.objectContaining({mock: 'http-server'}), - payloadStore: {mock: 'payload-store'}, - websocketURL: 'wss://mock.url/extensions', - }) + expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith( + expect.objectContaining({ + httpServer: expect.objectContaining({mock: 'http-server'}), + payloadStore: expect.objectContaining({mock: 'payload-store'}), + websocketURL: 'wss://mock.url/extensions', + }), + ) }) test('closes the http server, websocket and bundler when the process aborts', async () => { @@ -128,14 +134,76 @@ describe('devUIExtensions()', () => { const {getExtensions} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0] expect(getExtensions()).toStrictEqual(options.extensions) - const newUIExtension = {type: 'ui_extension', devUUID: 'BAR', isPreviewable: true} + const newUIExtension = { + type: 'ui_extension', + devUUID: 'BAR', + isPreviewable: true, + specification: {identifier: 'ui_extension'}, + } const newApp = { ...app, - allExtensions: [newUIExtension, {type: 'function_extension', devUUID: 'FUNCTION', isPreviewable: false}], + allExtensions: [ + newUIExtension, + { + type: 'function_extension', + devUUID: 'FUNCTION', + isPreviewable: false, + specification: {identifier: 'function'}, + }, + ], } options.appWatcher.emit('all', {app: newApp, appWasReloaded: true, extensionEvents: []}) // THEN expect(getExtensions()).toStrictEqual([newUIExtension]) }) + + test('passes getAppAssets callback to the HTTP server when appAssets provided', async () => { + // GIVEN + spyOnEverything() + const optionsWithAssets = { + ...options, + appAssets: {staticRoot: '/absolute/path/to/public'}, + } as unknown as ExtensionDevOptions + + // WHEN + await devUIExtensions(optionsWithAssets) + + // THEN + expect(server.setupHTTPServer).toHaveBeenCalledWith( + expect.objectContaining({ + getAppAssets: expect.any(Function), + }), + ) + + const {getAppAssets} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0] + expect(getAppAssets!()).toStrictEqual({staticRoot: '/absolute/path/to/public'}) + }) +}) + +describe('resolveAppAssets()', () => { + test('returns empty object when app has no admin config', () => { + const app = {configuration: {}, directory: '/app'} as unknown as Parameters[0] + + expect(resolveAppAssets(app)).toStrictEqual({}) + }) + + test('returns empty object when admin config has no static_root', () => { + const app = {configuration: {admin: {}}, directory: '/app'} as unknown as Parameters[0] + + expect(resolveAppAssets(app)).toStrictEqual({}) + }) + + test('returns staticRoot mapped to resolved absolute path when static_root is set', () => { + const app = { + configuration: {admin: {static_root: 'public'}}, + directory: '/app', + } as unknown as Parameters[0] + + const result = resolveAppAssets(app) + + expect(result).toStrictEqual({ + staticRoot: '/app/public', + }) + }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..c20c325d619 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -8,12 +8,19 @@ import { } from './extension/payload/store.js' import {AppEvent, AppEventWatcher, EventType} from './app-events/app-event-watcher.js' import {buildCartURLIfNeeded} from './extension/utilities.js' +import {AppLinkedInterface} from '../../models/app/app.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {Writable} from 'stream' +interface AppAssets { + [key: string]: string +} + export interface ExtensionDevOptions { /** * Standard output stream to send the output through. @@ -112,6 +119,20 @@ export interface ExtensionDevOptions { * The app watcher that emits events when the app is updated */ appWatcher: AppEventWatcher + + /** + * Map of asset key to absolute directory path for app-level assets (e.g., admin static_root) + */ + appAssets?: AppAssets +} + +export function resolveAppAssets(app: AppLinkedInterface): Record { + const appAssets: Record = {} + const staticRootPath = getPathValue(app.configuration, 'admin.static_root') + if (staticRootPath) { + appAssets.staticRoot = joinPath(app.directory, staticRootPath) + } + return appAssets } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -133,17 +154,29 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise payloadOptions.appAssets + const httpServer = setupHTTPServer({ + devOptions: payloadOptions, + payloadStore, + getExtensions, + getAppAssets, + }) outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout) const websocketConnection = setupWebsocketConnection({...payloadOptions, httpServer, payloadStore}) outputDebug(`Setting up the UI extensions bundler and file watching...`, payloadOptions.stdout) - const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => { + const eventHandler = async ({appWasReloaded, app, extensionEvents, appAssetsUpdated}: AppEvent) => { if (appWasReloaded) { extensions = app.allExtensions.filter((ext) => ext.isPreviewable) } + if (appAssetsUpdated && payloadOptions.appAssets) { + for (const assetKey of Object.keys(payloadOptions.appAssets)) { + payloadStore.updateAppAssetTimestamp(assetKey) + } + } + for (const event of extensionEvents) { if (!event.extension.isPreviewable) continue const status = event.buildResult?.status === 'ok' ? 'success' : 'error' diff --git a/packages/app/src/cli/services/dev/extension/payload/models.ts b/packages/app/src/cli/services/dev/extension/payload/models.ts index 796f93108bd..9495c31aab6 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface { url: string mobileUrl: string title: string + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } appId?: string store: string diff --git a/packages/app/src/cli/services/dev/extension/payload/store.test.ts b/packages/app/src/cli/services/dev/extension/payload/store.test.ts index cd9a5229da8..4be0b5a8adf 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.test.ts @@ -365,4 +365,143 @@ describe('ExtensionsPayloadStore()', () => { expect(onUpdateSpy).not.toHaveBeenCalled() }) }) + + describe('updateAppAssetTimestamp()', () => { + test('updates lastUpdated for the given asset key and emits update', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('staticRoot') + + // Then + const asset = extensionsPayloadStore.getRawPayload().app.assets?.staticRoot + expect(asset?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(asset?.lastUpdated).toBeGreaterThan(1000) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('does nothing if the asset key does not exist', () => { + // Given + const mockPayload = { + app: {assets: {}}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('nonExistent') + + // Then + expect(onUpdateSpy).not.toHaveBeenCalled() + }) + }) + + describe('updateAppAssets()', () => { + test('sets app.assets from the provided appAssets map', () => { + // Given + const mockPayload = { + app: {}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets({staticRoot: '/path/to/public'}, 'https://mock.url') + + // Then + const assets = extensionsPayloadStore.getRawPayload().app.assets + expect(assets?.staticRoot?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(assets?.staticRoot?.lastUpdated).toBeGreaterThan(0) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('removes app.assets when appAssets is undefined', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets(undefined, 'https://mock.url') + + // Then + expect(extensionsPayloadStore.getRawPayload().app.assets).toBeUndefined() + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + }) +}) + +describe('getExtensionsPayloadStoreRawPayload() with appAssets', () => { + test('populates app.assets when appAssets option is provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + appAssets: {staticRoot: '/path/to/public'}, + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toStrictEqual({ + staticRoot: { + url: 'https://mock-url.com/extensions/assets/staticRoot/', + lastUpdated: expect.any(Number), + }, + }) + }) + + test('does not set app.assets when appAssets option is not provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toBeUndefined() + }) }) diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index ae4919485c1..5b28e11787f 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -9,6 +9,7 @@ import {EventEmitter} from 'events' export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions { websocketURL: string + appAssets?: Record } export enum ExtensionsPayloadStoreEvent { @@ -19,7 +20,7 @@ export async function getExtensionsPayloadStoreRawPayload( options: Omit, bundlePath: string, ): Promise { - return { + const payload: ExtensionsEndpointPayload = { app: { title: options.appName, apiKey: options.apiKey, @@ -40,6 +41,19 @@ export async function getExtensionsPayloadStoreRawPayload( store: options.storeFqdn, extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), } + + if (options.appAssets) { + const assets: Record = {} + for (const assetKey of Object.keys(options.appAssets)) { + assets[assetKey] = { + url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(), + lastUpdated: Date.now(), + } + } + payload.app.assets = assets + } + + return payload } export class ExtensionsPayloadStore extends EventEmitter { @@ -170,6 +184,30 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + updateAppAssets(appAssets: Record | undefined, url: string) { + if (!appAssets || Object.keys(appAssets).length === 0) { + delete this.rawPayload.app.assets + } else { + const assets: Record = {} + for (const assetKey of Object.keys(appAssets)) { + assets[assetKey] = { + url: new URL(`/extensions/assets/${assetKey}/`, url).toString(), + lastUpdated: Date.now(), + } + } + this.rawPayload.app.assets = assets + } + this.emitUpdate([]) + } + + updateAppAssetTimestamp(assetKey: string) { + const asset = this.rawPayload.app.assets?.[assetKey] + if (asset) { + asset.lastUpdated = Date.now() + this.emitUpdate([]) + } + } + private emitUpdate(extensionIds: string[]) { this.emit(ExtensionsPayloadStoreEvent.Update, extensionIds) } diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 456c8364c61..b7a5bd7adec 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -2,6 +2,7 @@ import { corsMiddleware, devConsoleAssetsMiddleware, devConsoleIndexMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, getExtensionPointMiddleware, @@ -19,6 +20,7 @@ interface SetupHTTPServerOptions { devOptions: ExtensionsPayloadStoreOptions payloadStore: ExtensionsPayloadStore getExtensions: () => ExtensionInstance[] + getAppAssets?: () => Record | undefined } export function setupHTTPServer(options: SetupHTTPServerOptions) { @@ -28,6 +30,9 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(getLogMiddleware(options)) httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) + if (options.getAppAssets) { + httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.getAppAssets)) + } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) httpRouter.use('/extensions/:extensionId', getExtensionPayloadMiddleware(options)) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 69924d80cc9..651c4fc86c5 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -1,5 +1,6 @@ import { corsMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, fileServerMiddleware, @@ -573,3 +574,41 @@ describe('getExtensionPointMiddleware()', () => { expect(h3.sendRedirect).toHaveBeenCalledWith(event, 'http://www.mock.com/redirect/url', 307) }) }) + +describe('getAppAssetsMiddleware()', () => { + test('serves a file from the matching asset directory', async () => { + await inTemporaryDirectory(async (tmpDir: string) => { + const assetDir = joinPath(tmpDir, 'public') + await mkdir(assetDir) + await writeFile(joinPath(assetDir, 'icon.png'), 'png-content') + + const middleware = getAppAssetsMiddleware(() => ({staticRoot: assetDir})) + + const event = getMockEvent({ + params: {assetKey: 'staticRoot', filePath: 'icon.png'}, + }) + + const result = await middleware(event) + + expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png') + expect(result).toBe('png-content') + }) + }) + + test('returns 404 for an unknown asset key', async () => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + + const middleware = getAppAssetsMiddleware(() => ({staticRoot: '/some/path'})) + + const event = getMockEvent({ + params: {assetKey: 'unknown', filePath: 'icon.png'}, + }) + + await middleware(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'No app assets configured for key: unknown', + }) + }) +}) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index ed17a0f474d..7dd0993cac1 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -134,6 +134,23 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) }) +export function getAppAssetsMiddleware(getAppAssets: () => Record | undefined) { + return defineEventHandler(async (event) => { + const {assetKey = '', filePath = ''} = getRouterParams(event) + + const appAssets = getAppAssets() + const directory = appAssets?.[assetKey] + + if (!directory) { + return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`}) + } + + return fileServerMiddleware(event, { + filePath: joinPath(directory, filePath), + }) + }) +} + export function getLogMiddleware({devOptions}: GetExtensionsMiddlewareOptions) { return defineEventHandler((event) => { outputDebug(`UI extensions server received a ${event.method} request to URL ${event.path}`, devOptions.stdout) diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index 387d97ebeed..d9eee74cc20 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -1,5 +1,6 @@ import {BaseProcess, DevProcessFunction} from './types.js' -import {devUIExtensions} from '../extension.js' +import {devUIExtensions, resolveAppAssets} from '../extension.js' +import {AppLinkedInterface} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {buildCartURLIfNeeded} from '../extension/utilities.js' import {AppEventWatcher} from '../app-events/app-event-watcher.js' @@ -24,6 +25,7 @@ interface PreviewableExtensionOptions { grantedScopes: string[] previewableExtensions: ExtensionInstance[] appWatcher: AppEventWatcher + appAssets?: Record } export interface PreviewableExtensionProcess extends BaseProcess { @@ -47,6 +49,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -68,15 +71,18 @@ export const launchPreviewableExtensionProcess: DevProcessFunction & { + app: AppLinkedInterface allExtensions: ExtensionInstance[] checkoutCartUrl?: string }): Promise { @@ -84,6 +90,8 @@ export async function setupPreviewableExtensionsProcess({ const cartUrl = await buildCartURLIfNeeded(previewableExtensions, storeFqdn, checkoutCartUrl) + const appAssets = resolveAppAssets(app) + return { prefix: 'extensions', type: 'previewable-extension', @@ -94,6 +102,7 @@ export async function setupPreviewableExtensionsProcess({ storeFqdn, previewableExtensions, cartUrl, + appAssets: Object.keys(appAssets).length > 0 ? appAssets : undefined, ...options, }, } 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 9a2a3723740..87b862f4c61 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 @@ -149,6 +149,7 @@ export async function setupDevProcesses({ }) : undefined, await setupPreviewableExtensionsProcess({ + app: reloadedApp, allExtensions: reloadedApp.allExtensions, storeFqdn, storeId, diff --git a/packages/app/src/cli/services/init/template/npm.test.ts b/packages/app/src/cli/services/init/template/npm.test.ts index b8eeb21770a..4b5f3923b5a 100644 --- a/packages/app/src/cli/services/init/template/npm.test.ts +++ b/packages/app/src/cli/services/init/template/npm.test.ts @@ -5,7 +5,10 @@ import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit import {joinPath, moduleDirectory, normalizePath} from '@shopify/cli-kit/node/path' import {platform} from 'os' -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, platform: vi.fn()} +}) vi.mock('@shopify/cli-kit/node/node-package-manager') vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '1.2.3'})) diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index f6c16446640..f22f543286f 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -1,11 +1,13 @@ import {validateApp} from './validate.js' import {testAppLinked} from '../models/app/app.test-data.js' import {AppErrors, formatConfigurationError} from '../models/app/loader.js' +import metadata from '../metadata.js' import {describe, expect, test, vi} from 'vitest' 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' +vi.mock('../metadata.js', () => ({default: {addPublicMetadata: vi.fn()}})) vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() return { @@ -15,6 +17,16 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { }) vi.mock('@shopify/cli-kit/node/ui') +async function expectLastValidationMetadata(expected: { + cmd_app_validate_valid: boolean + cmd_app_validate_issue_count: number + cmd_app_validate_file_count: number +}) { + const getMetadata = vi.mocked(metadata.addPublicMetadata).mock.calls.at(-1)?.[0] + expect(getMetadata).toBeDefined() + await expect(Promise.resolve(getMetadata!())).resolves.toEqual(expected) +} + describe('formatConfigurationError', () => { test('returns plain message when no path', () => { expect(formatConfigurationError({file: 'foo.toml', message: 'something broke'})).toBe('something broke') @@ -34,6 +46,11 @@ describe('validateApp', () => { expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) expect(renderError).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: true, + cmd_app_validate_issue_count: 0, + cmd_app_validate_file_count: 0, + }) }) test('outputs json success when --json is enabled and there are no errors', async () => { @@ -42,6 +59,11 @@ describe('validateApp', () => { expect(outputResult).toHaveBeenCalledWith(JSON.stringify({valid: true, issues: []}, null, 2)) expect(renderSuccess).not.toHaveBeenCalled() expect(renderError).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: true, + cmd_app_validate_issue_count: 0, + cmd_app_validate_file_count: 0, + }) }) test('renders errors and throws when there are validation errors', async () => { @@ -58,6 +80,11 @@ describe('validateApp', () => { }) expect(renderSuccess).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 2, + }) }) test('outputs structured json issues when --json is enabled and there are validation errors', async () => { @@ -83,6 +110,11 @@ describe('validateApp', () => { ) expect(renderError).not.toHaveBeenCalled() expect(renderSuccess).not.toHaveBeenCalled() + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 2, + cmd_app_validate_file_count: 2, + }) }) test('renders success when errors object exists but is empty', async () => { @@ -111,5 +143,10 @@ describe('validateApp', () => { 2, ), ) + await expectLastValidationMetadata({ + cmd_app_validate_valid: false, + cmd_app_validate_issue_count: 1, + cmd_app_validate_file_count: 1, + }) }) }) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts index 00b634c90af..7655c369a5b 100644 --- a/packages/app/src/cli/services/validate.ts +++ b/packages/app/src/cli/services/validate.ts @@ -1,5 +1,6 @@ import {AppLinkedInterface} from '../models/app/app.js' import {formatConfigurationError} from '../models/app/loader.js' +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' @@ -8,10 +9,22 @@ interface ValidateAppOptions { json: boolean } +async function recordValidationMetadata(valid: boolean, errors: {file: string}[]) { + const fileCount = new Set(errors.map((error) => error.file)).size + + await metadata.addPublicMetadata(() => ({ + cmd_app_validate_valid: valid, + cmd_app_validate_issue_count: errors.length, + cmd_app_validate_file_count: fileCount, + })) +} + export async function validateApp(app: AppLinkedInterface, options: ValidateAppOptions = {json: false}): Promise { const appErrors = app.errors if (!appErrors || appErrors.isEmpty()) { + await recordValidationMetadata(true, []) + if (options.json) { outputResult(JSON.stringify({valid: true, issues: []}, null, 2)) return @@ -22,6 +35,7 @@ export async function validateApp(app: AppLinkedInterface, options: ValidateAppO } const errors = appErrors.getErrors() + await recordValidationMetadata(false, errors) if (options.json) { const issues = errors.map(({file, message, path, code}) => ({file, message, path, code})) diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 8813b2b8fd0..abcb38f0c36 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -113,7 +113,6 @@ "@opentelemetry/exporter-metrics-otlp-http": "0.57.0", "@opentelemetry/resources": "1.30.0", "@opentelemetry/sdk-metrics": "1.30.0", - "@opentelemetry/semantic-conventions": "1.28.0", "@types/archiver": "5.3.2", "ajv": "8.18.0", "ansi-escapes": "6.2.1", @@ -122,7 +121,6 @@ "chalk": "5.4.1", "change-case": "4.1.2", "color-json": "3.0.5", - "commondir": "1.0.1", "conf": "11.0.2", "deepmerge": "4.3.1", "dotenv": "16.4.7", @@ -133,7 +131,6 @@ "find-up": "6.3.0", "form-data": "4.0.4", "fs-extra": "11.1.0", - "get-port-please": "3.1.2", "gradient-string": "2.0.2", "graphql": "16.10.0", "graphql-request": "6.1.0", @@ -159,14 +156,11 @@ "stacktracey": "2.1.8", "strip-ansi": "7.1.0", "supports-hyperlinks": "3.1.0", - "tempy": "3.1.0", - "terminal-link": "3.0.0", "ts-error": "1.0.6", "which": "4.0.0", "zod": "3.24.4" }, "devDependencies": { - "@types/commondir": "^1.0.0", "@types/diff": "^5.2.3", "@types/fs-extra": "9.0.13", "@types/gradient-string": "^1.1.2", diff --git a/packages/cli-kit/src/private/node/analytics.ts b/packages/cli-kit/src/private/node/analytics.ts index 3034d8c1eff..0306936343c 100644 --- a/packages/cli-kit/src/private/node/analytics.ts +++ b/packages/cli-kit/src/private/node/analytics.ts @@ -103,6 +103,12 @@ export async function getSensitiveEnvironmentData(config: Interfaces.Config) { } function getShopifyEnvironmentVariables() { + // Agent callers can identify themselves today via SHOPIFY_* environment + // variables. The current contract is intentionally lightweight and is kept in + // the sensitive payload until we prove which dimensions deserve first-class + // Monorail fields, e.g. SHOPIFY_CLI_AGENT, SHOPIFY_CLI_AGENT_VERSION, + // SHOPIFY_CLI_AGENT_RUN_ID, SHOPIFY_CLI_AGENT_SESSION_ID, and + // SHOPIFY_CLI_AGENT_PROVIDER. return Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('SHOPIFY_'))) } diff --git a/packages/cli-kit/src/private/node/content-tokens.ts b/packages/cli-kit/src/private/node/content-tokens.ts index 932c53389c0..5f2cbb5eee3 100644 --- a/packages/cli-kit/src/private/node/content-tokens.ts +++ b/packages/cli-kit/src/private/node/content-tokens.ts @@ -1,7 +1,8 @@ import colors from '../../public/node/colors.js' import {OutputMessage, stringifyMessage} from '../../public/node/output.js' import {relativizePath} from '../../public/node/path.js' -import terminalLink from 'terminal-link' +import ansiEscapes from 'ansi-escapes' +import supportsHyperlinks from 'supports-hyperlinks' import cjs from 'color-json' import type {Change} from 'diff' @@ -35,7 +36,10 @@ export class LinkContentToken extends ContentToken { const text = colors.green(stringifyMessage(this.value)) const url = this.link ?? '' const defaultFallback = this.value === this.link ? text : `${text} ( ${url} )` - return terminalLink(text, url, {fallback: () => this.fallback ?? defaultFallback}) + if (supportsHyperlinks.stdout) { + return ansiEscapes.link(text, url) + } + return this.fallback ?? defaultFallback } } diff --git a/packages/cli-kit/src/private/node/temp-dir.ts b/packages/cli-kit/src/private/node/temp-dir.ts new file mode 100644 index 00000000000..fbbdfeb3b66 --- /dev/null +++ b/packages/cli-kit/src/private/node/temp-dir.ts @@ -0,0 +1,8 @@ +import {realpath} from 'fs/promises' +import {tmpdir} from 'os' + +// Captured at module load time, before test mocks can interfere. +// Async realpath resolves symlinks (e.g. /var -> /private/var on macOS) +// and 8.3 short names on Windows (e.g. RUNNER~1 -> runneradmin), +// matching tempy's temp-dir behavior. +export const systemTempDir = await realpath(tmpdir()) diff --git a/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts b/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts index 7c9dfa5cebc..49acc905139 100644 --- a/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts +++ b/packages/cli-kit/src/private/node/themes/generate-theme-name.test.ts @@ -3,7 +3,10 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {hostname} from 'os' import {randomBytes} from 'crypto' -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, hostname: vi.fn()} +}) vi.mock('crypto') describe('generateThemeName', () => { diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index 60a3a973541..c65237fc338 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -32,7 +32,10 @@ import * as os from 'os' vi.mock('../common/array.js') vi.mock('fast-glob') -vi.mock('os') +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, EOL: actual.EOL} +}) describe('inTemporaryDirectory', () => { test('ties the lifecycle of the temporary directory to the lifecycle of the callback', async () => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index a20f0df445b..4163b73b2d9 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -2,6 +2,7 @@ import {outputContent, outputToken, outputDebug} from './output.js' import {joinPath, normalizePath} from './path.js' import {OverloadParameters} from '../../private/common/ts/overloaded-parameters.js' import {getRandomName, RandomNameFamily} from '../common/string.js' +import {systemTempDir} from '../../private/node/temp-dir.js' import { copy as fsCopy, ensureFile as fsEnsureFile, @@ -13,7 +14,6 @@ import { // @ts-ignore } from 'fs-extra/esm' -import {temporaryDirectory, temporaryDirectoryTask} from 'tempy' import {sep, join} from 'pathe' import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up' import {minimatch} from 'minimatch' @@ -29,6 +29,7 @@ import { constants as fsConstants, existsSync as fsFileExistsSync, unlinkSync as fsUnlinkSync, + mkdtempSync as fsMkdtempSync, accessSync, ReadStream, WriteStream, @@ -75,7 +76,12 @@ export function stripUpPath(path: string, strip: number): string { * @param callback - The callback that receives the temporary directory. */ export async function inTemporaryDirectory(callback: (tmpDir: string) => T | Promise): Promise { - return temporaryDirectoryTask(callback) + const tmpDir = await fsMkdtemp(join(systemTempDir, 'tmp-')) + try { + return await callback(tmpDir) + } finally { + await fsRm(tmpDir, {recursive: true, force: true, maxRetries: 2}) + } } /** @@ -83,7 +89,7 @@ export async function inTemporaryDirectory(callback: (tmpDir: string) => T | * @returns - The path to the temporary directory. */ export function tempDirectory(): string { - return temporaryDirectory() + return fsMkdtempSync(join(systemTempDir, 'tmp-')) } /** diff --git a/packages/cli-kit/src/public/node/monorail.ts b/packages/cli-kit/src/public/node/monorail.ts index b687ec9fee4..dc6130bc372 100644 --- a/packages/cli-kit/src/public/node/monorail.ts +++ b/packages/cli-kit/src/public/node/monorail.ts @@ -88,6 +88,10 @@ export interface Schemas { cmd_app_linked_config_uses_cli_managed_urls?: Optional cmd_app_warning_api_key_deprecation_displayed?: Optional cmd_app_deployment_mode?: Optional + cmd_app_validate_json?: Optional + cmd_app_validate_valid?: Optional + cmd_app_validate_issue_count?: Optional + cmd_app_validate_file_count?: Optional // Dev related commands cmd_dev_tunnel_type?: Optional diff --git a/packages/cli-kit/src/public/node/path.test.ts b/packages/cli-kit/src/public/node/path.test.ts index e2dc5d69c8d..4972b3ac276 100644 --- a/packages/cli-kit/src/public/node/path.test.ts +++ b/packages/cli-kit/src/public/node/path.test.ts @@ -1,4 +1,4 @@ -import {relativizePath, normalizePath, cwd, sniffForPath} from './path.js' +import {relativizePath, normalizePath, cwd, sniffForPath, commonParentDirectory} from './path.js' import {describe, test, expect} from 'vitest' describe('relativize', () => { @@ -25,6 +25,40 @@ describe('cwd', () => { }) }) +describe('commonParentDirectory', () => { + // Parity tests with the original 'commondir' npm package (v1.0.1) + test('finds common parent for paths sharing a prefix', () => { + expect(commonParentDirectory('/foo', '/foo/bar')).toBe('/foo') + expect(commonParentDirectory('/foo/bar', '/foo//bar/baz')).toBe('/foo/bar') + }) + + test('finds deepest common ancestor', () => { + expect(commonParentDirectory('/a/b/c', '/a/b')).toBe('/a/b') + expect(commonParentDirectory('/a/b', '/a/b/c/d/e')).toBe('/a/b') + }) + + test('returns root when paths diverge at top level', () => { + expect(commonParentDirectory('/x/y/z/w', '/xy/z')).toBe('/') + }) + + test('handles Windows-style paths', () => { + expect(commonParentDirectory('X:\\foo', 'X:\\\\foo\\bar')).toBe('X:/foo') + expect(commonParentDirectory('X:\\a\\b\\c', 'X:\\a\\b')).toBe('X:/a/b') + }) + + test('returns root for completely divergent Windows paths', () => { + expect(commonParentDirectory('X:\\x\\y\\z\\w', '\\\\xy\\z')).toBe('/') + }) + + test('returns root for single-component paths', () => { + expect(commonParentDirectory('/', '/')).toBe('/') + }) + + test('handles identical paths', () => { + expect(commonParentDirectory('/a/b/c', '/a/b/c')).toBe('/a/b/c') + }) +}) + describe('sniffForPath', () => { test('returns the path if provided', () => { // Given diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index 20ce55840cf..697dad3ed92 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -1,4 +1,3 @@ -import commondir from 'commondir' import { relative, dirname as patheDirname, @@ -106,6 +105,23 @@ export function parsePath(path: string): {root: string; dir: string; base: strin return parse(path) } +/** + * Returns the longest common parent directory of two absolute paths. + * + * @param first - First absolute path. + * @param second - Second absolute path. + * @returns The common parent directory, or '/' if they share only the root. + */ +export function commonParentDirectory(first: string, second: string): string { + const firstParts = first.split(/\/+|\\+/) + const secondParts = second.split(/\/+|\\+/) + let i = 0 + while (i < firstParts.length && i < secondParts.length && firstParts[i] === secondParts[i]) { + i++ + } + return i > 1 ? firstParts.slice(0, i).join('/') : '/' +} + /** * Given an absolute filesystem path, it makes it relative to * the current working directory. This is useful when logging paths @@ -117,7 +133,7 @@ export function parsePath(path: string): {root: string; dir: string; base: strin * @returns Relativized path. */ export function relativizePath(path: string, dir: string = cwd()): string { - const result = commondir([path, dir]) + const result = commonParentDirectory(path, dir) const relativePath = relative(dir, path) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/cli-kit/src/public/node/tcp-retry.test.ts b/packages/cli-kit/src/public/node/tcp-retry.test.ts new file mode 100644 index 00000000000..955ab8737d6 --- /dev/null +++ b/packages/cli-kit/src/public/node/tcp-retry.test.ts @@ -0,0 +1,63 @@ +import {AbortError} from './error.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {EventEmitter} from 'events' + +vi.mock('./system.js', async (importOriginal) => { + const actual: any = await importOriginal() + return {...actual, sleep: vi.fn()} +}) + +let callCount = 0 +let failCount = 0 + +vi.mock('net', () => { + return { + createServer: () => { + callCount++ + const server = new EventEmitter() as any + server.unref = () => server + server.listen = (_port: number, _host: string, cb?: () => void) => { + if (callCount <= failCount) { + process.nextTick(() => server.emit('error', new Error('mock port allocation failure'))) + } else { + server.address = () => ({port: 9999}) + process.nextTick(() => cb?.()) + } + return server + } + server.close = (cb?: () => void) => { + cb?.() + return server + } + return server + }, + } +}) + +beforeEach(() => { + callCount = 0 + failCount = 0 +}) + +describe('getAvailableTCPPort retry behavior', () => { + test('retries and returns port after transient error', async () => { + const {getAvailableTCPPort} = await import('./tcp.js') + const {sleep} = await import('./system.js') + + failCount = 1 + + const got = await getAvailableTCPPort(undefined, {waitTimeInSeconds: 0}) + expect(got).toBe(9999) + expect(sleep).toHaveBeenCalledOnce() + }) + + test('throws AbortError when all retries are exhausted', async () => { + const {getAvailableTCPPort} = await import('./tcp.js') + + failCount = 100 + + await expect(() => getAvailableTCPPort(undefined, {maxTries: 3, waitTimeInSeconds: 0})).rejects.toThrowError( + AbortError, + ) + }) +}) diff --git a/packages/cli-kit/src/public/node/tcp.test.ts b/packages/cli-kit/src/public/node/tcp.test.ts index 530b595fa9f..3a42698e4fc 100644 --- a/packages/cli-kit/src/public/node/tcp.test.ts +++ b/packages/cli-kit/src/public/node/tcp.test.ts @@ -1,100 +1,93 @@ import {getAvailableTCPPort, checkPortAvailability} from './tcp.js' -import * as system from './system.js' -import {AbortError} from './error.js' -import * as port from 'get-port-please' -import {describe, expect, test, vi} from 'vitest' - -vi.mock('get-port-please') - -const errorMessage = 'Unable to generate random port' +import {describe, expect, test} from 'vitest' +import {createServer} from 'net' describe('getAvailableTCPPort', () => { - test('returns random port if the number retries is not exceeded', async () => { - // Given - vi.mocked(port.getRandomPort).mockRejectedValueOnce(new Error(errorMessage)) - vi.mocked(port.getRandomPort).mockResolvedValue(5) - const debugError = vi.spyOn(system, 'sleep') - - // When - const got = await getAvailableTCPPort(undefined, {waitTimeInSeconds: 0}) - - // Then - expect(got).toBe(5) - expect(debugError).toHaveBeenCalledOnce() + test('returns a valid port number', async () => { + const port = await getAvailableTCPPort() + expect(port).toBeGreaterThan(0) + expect(port).toBeLessThanOrEqual(65535) }) - test('throws an abort exception with same error message received from third party getRandomPort if the number retries is exceeded', async () => { - // Given - const maxTries = 5 - for (let i = 0; i < maxTries; i++) { - vi.mocked(port.getRandomPort).mockRejectedValueOnce(new Error(errorMessage)) - } - - // When/Then - await expect(() => getAvailableTCPPort(undefined, {waitTimeInSeconds: 0})).rejects.toThrowError( - new AbortError(errorMessage), - ) + test('returns the preferred port when it is available', async () => { + const freePort = await getAvailableTCPPort() + const got = await getAvailableTCPPort(freePort) + expect(got).toBe(freePort) }) - test('returns the provided port when it is available', async () => { - // Given - vi.mocked(port.checkPort).mockResolvedValue(666) - - // When - const got = await getAvailableTCPPort(666) - - // Then - expect(got).toBe(666) + test('returns a different port when the preferred one is in use', async () => { + const server = createServer() + const occupiedPort = await new Promise((resolve) => { + server.listen(0, 'localhost', () => { + const address = server.address() + resolve((address as {port: number}).port) + }) + }) + + try { + const got = await getAvailableTCPPort(occupiedPort) + expect(got).not.toBe(occupiedPort) + expect(got).toBeGreaterThan(0) + } finally { + server.close() + } }) - test('returns a random port when the provided one is not available', async () => { - // Given - vi.mocked(port.checkPort).mockResolvedValue(false) - vi.mocked(port.getRandomPort).mockResolvedValue(5) - - // When - const got = await getAvailableTCPPort(666) - - // Then - expect(got).toBe(5) + test('returns unique ports across multiple calls', async () => { + const ports = new Set() + for (let i = 0; i < 5; i++) { + // eslint-disable-next-line no-await-in-loop + const port = await getAvailableTCPPort() + ports.add(port) + } + expect(ports.size).toBe(5) }) - test('reserves random ports and does not reuse them', async () => { - vi.mocked(port.checkPort).mockResolvedValue(false) - vi.mocked(port.getRandomPort).mockResolvedValueOnce(55).mockResolvedValueOnce(55).mockResolvedValueOnce(66) - - let got = await getAvailableTCPPort(123) - expect(got).toBe(55) - - got = await getAvailableTCPPort(123) - expect(got).toBe(66) + test('returns unique ports and all are bindable', async () => { + const ports: number[] = [] + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + ports.push(await getAvailableTCPPort()) + } + expect(new Set(ports).size).toBe(3) + + // Verify all ports are actually bindable + const servers = await Promise.all( + ports.map( + (port) => + new Promise>((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(port, 'localhost', () => resolve(server)) + }), + ), + ) + // All three bound successfully — clean up + await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve())))) }) }) describe('checkPortAvailability', () => { test('returns true when port is available', async () => { - // Given - const portNumber = 3000 - vi.mocked(port.checkPort).mockResolvedValue(portNumber) - - // When - const result = await checkPortAvailability(portNumber) - - // Then + const freePort = await getAvailableTCPPort() + const result = await checkPortAvailability(freePort) expect(result).toBe(true) - expect(port.checkPort).toHaveBeenCalledWith(portNumber, 'localhost') }) - test('returns false when port is not available', async () => { - // Given - const portNumber = 3000 - vi.mocked(port.checkPort).mockResolvedValue(false) - - // When - const result = await checkPortAvailability(portNumber) - - // Then - expect(result).toBe(false) - expect(port.checkPort).toHaveBeenCalledWith(portNumber, 'localhost') + test('returns false when port is in use', async () => { + const server = createServer() + const occupiedPort = await new Promise((resolve) => { + server.listen(0, 'localhost', () => { + const address = server.address() + resolve((address as {port: number}).port) + }) + }) + + try { + const result = await checkPortAvailability(occupiedPort) + expect(result).toBe(false) + } finally { + server.close() + } }) }) diff --git a/packages/cli-kit/src/public/node/tcp.ts b/packages/cli-kit/src/public/node/tcp.ts index 86d05ec552e..ed194b450b7 100644 --- a/packages/cli-kit/src/public/node/tcp.ts +++ b/packages/cli-kit/src/public/node/tcp.ts @@ -1,7 +1,7 @@ import {sleep} from './system.js' import {AbortError} from './error.js' import {outputDebug, outputContent, outputToken} from './output.js' -import * as port from 'get-port-please' +import {createServer} from 'net' interface GetTCPPortOptions { waitTimeInSeconds?: number @@ -23,14 +23,14 @@ export async function getAvailableTCPPort(preferredPort?: number, options?: GetT return preferredPort } outputDebug(outputContent`Getting a random port...`) - let randomPort = await retryOnError(() => port.getRandomPort(host()), options?.maxTries, options?.waitTimeInSeconds) + let randomPort = await retryOnError(() => getRandomPort(), options?.maxTries, options?.waitTimeInSeconds) for (let i = 0; i < (options?.maxTries ?? 5); i++) { if (!obtainedRandomPorts.has(randomPort)) { break } // eslint-disable-next-line no-await-in-loop - randomPort = await retryOnError(() => port.getRandomPort(host()), options?.maxTries, options?.waitTimeInSeconds) + randomPort = await retryOnError(() => getRandomPort(), options?.maxTries, options?.waitTimeInSeconds) } outputDebug(outputContent`Random port obtained: ${outputToken.raw(`${randomPort}`)}`) @@ -45,13 +45,36 @@ export async function getAvailableTCPPort(preferredPort?: number, options?: GetT * @returns A promise that resolves with a boolean indicating if the port is available. */ export async function checkPortAvailability(portNumber: number): Promise { - return (await port.checkPort(portNumber, host())) === portNumber + return new Promise((resolve) => { + const server = createServer() + server.unref() + server.once('error', () => resolve(false)) + server.listen(portNumber, 'localhost', () => { + server.close(() => resolve(true)) + }) + }) } -function host(): string | undefined { - // The get-port-please library does not work as expected when HOST env var is defined, - // so explicitly set the host to localhost to avoid conflicts - return 'localhost' +/** + * Gets a random available port by binding to port 0 on localhost. + * + * @returns A promise that resolves with an available port number. + */ +function getRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.unref() + server.once('error', reject) + server.listen(0, 'localhost', () => { + const address = server.address() + if (address && typeof address === 'object') { + const assignedPort = address.port + server.close(() => resolve(assignedPort)) + } else { + server.close(() => reject(new Error('Unable to determine assigned port'))) + } + }) + }) } /** diff --git a/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts b/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts index 1cdad01c0ea..76bd3883092 100644 --- a/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts +++ b/packages/cli-kit/src/public/node/vendor/otel-js/service/DefaultOtelService/DefaultMeterProvider.ts @@ -2,7 +2,6 @@ import {InstantaneousMetricReader} from '../../export/InstantaneousMetricReader. import {OTLPMetricExporter, OTLPMetricExporterOptions} from '@opentelemetry/exporter-metrics-otlp-http' import {Resource} from '@opentelemetry/resources' import {AggregationTemporality, ConsoleMetricExporter, MeterProvider} from '@opentelemetry/sdk-metrics' -import {SemanticResourceAttributes} from '@opentelemetry/semantic-conventions' export type Environment = 'production' | 'staging' | 'local' @@ -19,7 +18,7 @@ export class DefaultMeterProvider extends MeterProvider { constructor({serviceName, env, throttleLimit, useXhr, otelEndpoint}: DefaultMeterProviderOptions) { super({ resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: serviceName, + ['service.name']: serviceName, }), }) diff --git a/packages/cli/README.md b/packages/cli/README.md index ce2d7756c14..9a25dfeb007 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2057,9 +2057,10 @@ Authenticate an app against a store for store commands. ``` USAGE - $ shopify store auth --scopes -s [--no-color] [--verbose] + $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -2076,6 +2077,8 @@ DESCRIPTION EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products + + $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` ## `shopify store execute` @@ -2084,10 +2087,11 @@ Execute GraphQL queries and mutations on a store. ``` USAGE - $ shopify store execute -s [--allow-mutations] [--no-color] [--output-file ] [-q ] + $ shopify store execute -s [--allow-mutations] [-j] [--no-color] [--output-file ] [-q ] [--query-file ] [--variable-file | -v ] [--verbose] [--version ] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute against. @@ -2121,6 +2125,8 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}' $ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations + + $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` ## `shopify theme check` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b9be73bdcd4..82dbca3b896 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5743,9 +5743,19 @@ "descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.", "enableJsonFlag": false, "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json" ], "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", @@ -5803,7 +5813,8 @@ "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{\"id\":\"gid://shopify/Product/1\"}'", - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json" ], "flags": { "allow-mutations": { @@ -5813,6 +5824,15 @@ "name": "allow-mutations", "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index d2bc2c9a33d..d3b5276b1ba 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,12 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreAuth from './auth.js' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' -vi.mock('../../services/store/auth.js') +vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/auth/result.js', () => ({ + createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), +})) describe('store auth command', () => { beforeEach(() => { @@ -12,15 +16,33 @@ describe('store auth command', () => { test('passes parsed flags through to the auth service', async () => { await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products']) - expect(authenticateStoreWithApp).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }) + expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + {presenter: {format: 'text'}}, + ) + }) + + test('passes a json presenter when --json is provided', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products', '--json']) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + {presenter: {format: 'json'}}, + ) }) test('defines the expected flags', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() + expect(StoreAuth.flags.json).toBeDefined() expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index 1fe48e3f3dc..a42d4dc327e 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -1,8 +1,9 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' @@ -13,10 +14,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json', + ] static flags = { ...globalFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'The myshopify.com domain of the store to authenticate against.', @@ -34,9 +39,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th async run(): Promise { const {flags} = await this.parse(StoreAuth) - await authenticateStoreWithApp({ - store: flags.store, - scopes: flags.scopes, - }) + await authenticateStoreWithApp( + { + store: flags.store, + scopes: flags.scopes, + }, + { + presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + }, + ) } } diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index e890b5332a0..ffd339ac323 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,15 +1,18 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreExecute from './execute.js' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' -vi.mock('../../services/store/execute.js') +vi.mock('../../services/store/execute/index.js') +vi.mock('../../services/store/execute/result.js') describe('store execute command', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) }) - test('passes the inline query through to the service', async () => { + test('passes the inline query through to the service and writes the result', async () => { await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }']) expect(executeStoreOperation).toHaveBeenCalledWith({ @@ -18,10 +21,14 @@ describe('store execute command', () => { queryFile: undefined, variables: undefined, variableFile: undefined, - outputFile: undefined, version: undefined, allowMutations: false, }) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'text', + ) }) test('passes the query file through to the service', async () => { @@ -36,6 +43,16 @@ describe('store execute command', () => { ) }) + test('writes json output when --json is provided', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json']) + + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'json', + ) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() @@ -43,5 +60,6 @@ describe('store execute command', () => { expect(StoreExecute.flags.variables).toBeDefined() expect(StoreExecute.flags['variable-file']).toBeDefined() expect(StoreExecute.flags['allow-mutations']).toBeDefined() + expect(StoreExecute.flags.json).toBeDefined() }) }) diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index 661c8af9a24..a184d6d3d8a 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -1,9 +1,10 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' @@ -20,10 +21,12 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', `<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}'`, '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }" --json', ] static flags = { ...globalFlags, + ...jsonFlag, query: Flags.string({ char: 'q', description: 'The GraphQL query or mutation, as a string.', @@ -75,15 +78,16 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte async run(): Promise { const {flags} = await this.parse(StoreExecute) - await executeStoreOperation({ + const result = await executeStoreOperation({ store: flags.store, query: flags.query, queryFile: flags['query-file'], variables: flags.variables, variableFile: flags['variable-file'], - outputFile: flags['output-file'], version: flags.version, allowMutations: flags['allow-mutations'], }) + + await writeOrOutputStoreExecuteResult(result, flags['output-file'], flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts deleted file mode 100644 index 137c3f2761f..00000000000 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, -} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' - -vi.mock('./session.js') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - } -}) - -describe('prepareAdminStoreGraphQLContext', () => { - const store = 'shop.myshopify.com' - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - refreshToken: 'refresh-token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: '2026-03-27T01:00:00.000Z', - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(isSessionExpired).mockReturnValue(false) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - }) - - test('returns the stored admin session and latest supported version by default', async () => { - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result).toEqual({ - adminSession: { - token: 'token', - storeFqdn: store, - }, - version: '2025-10', - sessionUserId: '42', - }) - }) - - test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'fresh-token', - refresh_token: 'fresh-refresh-token', - expires_in: 3600, - refresh_token_expires_in: 7200, - }), - ), - } as any) - - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result.adminSession.token).toBe('fresh-token') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store, - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - }), - ) - }) - - test('returns the requested API version when provided', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) - - expect(result.version).toBe('2025-07') - }) - - test('allows unstable without validating against fetched versions', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - - expect(result.version).toBe('unstable') - expect(fetchApiVersions).not.toHaveBeenCalled() - }) - - test('throws when no stored auth exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No stored app authentication found for ${store}.`, - tryMessage: 'To create stored auth for this store, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], - }) - }) - - test('clears stored auth when token refresh fails', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 401, - text: vi.fn().mockResolvedValue('bad refresh'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh failed for ${store} (HTTP 401).`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined}) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No refresh token stored for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh returned an invalid response for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue('not-json'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Received an invalid refresh response') - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears stored auth and prompts re-auth when API version lookup fails with invalid auth', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue( - new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), - ) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when the requested API version is invalid', async () => { - await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( - 'Invalid API version', - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts deleted file mode 100644 index f014e5b9e9e..00000000000 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ /dev/null @@ -1,166 +0,0 @@ -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, - StoredStoreAppSession, -} from './session.js' - -export interface AdminStoreGraphQLContext { - adminSession: AdminSession - version: string - sessionUserId: string -} - -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} - -async function resolveApiVersion(options: { - session: StoredStoreAppSession - adminSession: AdminSession - userSpecifiedVersion?: string -}): Promise { - const {session, adminSession, userSpecifiedVersion} = options - - if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion - - let availableVersions - try { - availableVersions = await fetchApiVersions(adminSession) - } catch (error) { - if ( - error instanceof AbortError && - error.message.includes(`Error connecting to your store ${adminSession.storeFqdn}:`) && - /\b(?:401|404)\b/.test(error.message) - ) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Stored app authentication for ${session.store} is no longer valid.`, - session.store, - session.scopes.join(','), - ) - } - - throw error - } - - if (!userSpecifiedVersion) { - const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) - return supportedVersions.sort().reverse()[0]! - } - - const versionList = availableVersions.map((version) => version.handle) - if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion - - throw new AbortError(`Invalid API version: ${userSpecifiedVersion}`, `Allowed versions: ${versionList.join(', ')}`) -} - -export async function prepareAdminStoreGraphQLContext(input: { - store: string - userSpecifiedVersion?: string -}): Promise { - const session = await loadStoredStoreSession(input.store) - const adminSession = { - token: session.accessToken, - storeFqdn: session.store, - } - const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) - - return {adminSession, version, sessionUserId: session.userId} -} diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts deleted file mode 100644 index 0b38fc842d3..00000000000 --- a/packages/cli/src/cli/services/store/auth.ts +++ /dev/null @@ -1,492 +0,0 @@ -import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' -import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {setStoredStoreAppSession} from './session.js' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {randomUUID} from '@shopify/cli-kit/node/crypto' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' -import {openURL} from '@shopify/cli-kit/node/system' -import {createHash, randomBytes, timingSafeEqual} from 'crypto' -import {createServer} from 'http' - -interface StoreAuthInput { - store: string - scopes: string -} - -interface StoreTokenResponse { - access_token: string - token_type?: string - scope?: string - expires_in?: number - refresh_token?: string - refresh_token_expires_in?: number - associated_user_scope?: string - associated_user?: { - id: number - first_name?: string - last_name?: string - email?: string - account_owner?: boolean - locale?: string - collaborator?: boolean - email_verified?: boolean - } -} - -interface StoreAuthorizationContext { - store: string - scopes: string[] - state: string - port: number - redirectUri: string - authorizationUrl: string - codeVerifier: string -} - -interface StoreAuthBootstrap { - authorization: StoreAuthorizationContext - waitForAuthCodeOptions: WaitForAuthCodeOptions - exchangeCodeForToken: (code: string) => Promise -} - -interface WaitForAuthCodeOptions { - store: string - state: string - port: number - timeoutMs?: number - onListening?: () => void | Promise -} - -export function generateCodeVerifier(): string { - return randomBytes(32).toString('base64url') -} - -export function computeCodeChallenge(verifier: string): string { - return createHash('sha256').update(verifier).digest('base64url') -} - -export function parseStoreAuthScopes(input: string): string[] { - const scopes = input - .split(',') - .map((scope) => scope.trim()) - .filter(Boolean) - - if (scopes.length === 0) { - throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') - } - - return [...new Set(scopes)] -} - -function expandImpliedStoreScopes(scopes: string[]): Set { - const expandedScopes = new Set(scopes) - - for (const scope of scopes) { - const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) - if (matches) { - expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) - } - } - - return expandedScopes -} - -function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { - if (!tokenResponse.scope) { - outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) - return requestedScopes - } - - const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) - const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) - const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) - - if (missingScopes.length > 0) { - throw new AbortError( - 'Shopify granted fewer scopes than were requested.', - `Missing scopes: ${missingScopes.join(', ')}.`, - [ - 'Update the app or store installation scopes.', - 'See https://shopify.dev/app/scopes', - 'Re-run shopify store auth.', - ], - ) - } - - return grantedScopes -} - -export function buildStoreAuthUrl(options: { - store: string - scopes: string[] - state: string - redirectUri: string - codeChallenge: string -}): string { - const params = new URLSearchParams() - params.set('client_id', STORE_AUTH_APP_CLIENT_ID) - params.set('scope', options.scopes.join(',')) - params.set('redirect_uri', options.redirectUri) - params.set('state', options.state) - params.set('response_type', 'code') - params.set('code_challenge', options.codeChallenge) - params.set('code_challenge_method', 'S256') - - return `https://${options.store}/admin/oauth/authorize?${params.toString()}` -} - -function renderAuthCallbackPage(title: string, message: string): string { - const safeTitle = title - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - const safeMessage = message - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - - return ` - - - - - ${safeTitle} - - -
-
-

${safeTitle}

-

${safeMessage}

-
-
- -` -} - -export async function waitForStoreAuthCode({ - store, - state, - port, - timeoutMs = 5 * 60 * 1000, - onListening, -}: WaitForAuthCodeOptions): Promise { - const normalizedStore = normalizeStoreFqdn(store) - - return new Promise((resolve, reject) => { - let settled = false - let isListening = false - - const timeout = setTimeout(() => { - settleWithError(new AbortError('Timed out waiting for OAuth callback.')) - }, timeoutMs) - - const server = createServer((req, res) => { - const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) - - if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { - res.statusCode = 404 - res.end('Not found') - return - } - - const {searchParams} = requestUrl - - const fail = (error: AbortError | string, tryMessage?: string) => { - const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error - - res.statusCode = 400 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settleWithError(abortError)) - res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) - } - - const returnedStore = searchParams.get('shop') - outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) - - if (!returnedStore) { - fail('OAuth callback store does not match the requested store.') - return - } - - const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) - if (normalizedReturnedStore !== normalizedStore) { - fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) - return - } - - const returnedState = searchParams.get('state') - if (!returnedState || !constantTimeEqual(returnedState, state)) { - fail('OAuth callback state does not match the original request.') - return - } - - const error = searchParams.get('error') - if (error) { - fail(`Shopify returned an OAuth error: ${error}`) - return - } - - const code = searchParams.get('code') - if (!code) { - fail('OAuth callback did not include an authorization code.') - return - } - - outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) - - res.statusCode = 200 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settle(() => resolve(code))) - res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) - }) - - const settle = (callback: () => void) => { - if (settled) return - settled = true - clearTimeout(timeout) - - const finalize = () => { - callback() - } - - if (!isListening) { - finalize() - return - } - - server.close(() => { - isListening = false - finalize() - }) - server.closeIdleConnections?.() - } - - const settleWithError = (error: Error) => { - settle(() => reject(error)) - } - - server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - settleWithError( - new AbortError( - `Port ${port} is already in use.`, - `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, - ), - ) - return - } - - settleWithError(error) - }) - - server.listen(port, '127.0.0.1', async () => { - isListening = true - outputDebug( - outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, - ) - - if (!onListening) return - - try { - await onListening() - } catch (error) { - settleWithError(error instanceof Error ? error : new Error(String(error))) - } - }) - }) -} - -function constantTimeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false - return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) -} - -export async function exchangeStoreAuthCodeForToken(options: { - store: string - code: string - codeVerifier: string - redirectUri: string -}): Promise { - const endpoint = `https://${options.store}/admin/oauth/access_token` - - outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - code: options.code, - code_verifier: options.codeVerifier, - redirect_uri: options.redirectUri, - }), - }) - - const body = await response.text() - if (!response.ok) { - outputDebug( - outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - throw new AbortError( - `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, - body || response.statusText, - ) - } - - let parsed: StoreTokenResponse - try { - parsed = JSON.parse(body) as StoreTokenResponse - } catch { - throw new AbortError('Received an invalid token response from Shopify.') - } - - outputDebug( - outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, - ) - - return parsed -} - -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - -interface StoreAuthDependencies { - openURL: typeof openURL - waitForStoreAuthCode: typeof waitForStoreAuthCode - exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken - presenter: StoreAuthPresenter -} - -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, -} - -const defaultStoreAuthDependencies: StoreAuthDependencies = { - openURL, - waitForStoreAuthCode, - exchangeStoreAuthCodeForToken, - presenter: defaultStoreAuthPresenter, -} - -function createPkceBootstrap( - input: StoreAuthInput, - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken, -): StoreAuthBootstrap { - const store = normalizeStoreFqdn(input.store) - const scopes = parseStoreAuthScopes(input.scopes) - const port = DEFAULT_STORE_AUTH_PORT - const state = randomUUID() - const redirectUri = storeAuthRedirectUri(port) - const codeVerifier = generateCodeVerifier() - const codeChallenge = computeCodeChallenge(codeVerifier) - const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) - - outputDebug( - outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, - ) - - return { - authorization: { - store, - scopes, - state, - port, - redirectUri, - authorizationUrl, - codeVerifier, - }, - waitForAuthCodeOptions: { - store, - state, - port, - }, - exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), - } -} - -export async function authenticateStoreWithApp( - input: StoreAuthInput, - dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, -): Promise { - const bootstrap = createPkceBootstrap(input, dependencies.exchangeStoreAuthCodeForToken) - const { - authorization: {store, scopes, redirectUri, authorizationUrl}, - } = bootstrap - - dependencies.presenter.openingBrowser() - - const code = await dependencies.waitForStoreAuthCode({ - ...bootstrap.waitForAuthCodeOptions, - onListening: async () => { - const opened = await dependencies.openURL(authorizationUrl) - if (!opened) dependencies.presenter.manualAuthUrl(authorizationUrl) - }, - }) - const tokenResponse = await bootstrap.exchangeCodeForToken(code) - - const userId = tokenResponse.associated_user?.id?.toString() - if (!userId) { - throw new AbortError('Shopify did not return associated user information for the online access token.') - } - - const now = Date.now() - const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - - setStoredStoreAppSession({ - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - // Store the raw scopes returned by Shopify. Validation may treat implied - // write_* -> read_* permissions as satisfied, so callers should not assume - // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, scopes), - acquiredAt: new Date(now).toISOString(), - expiresAt, - refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in - ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() - : undefined, - associatedUser: tokenResponse.associated_user - ? { - id: tokenResponse.associated_user.id, - email: tokenResponse.associated_user.email, - firstName: tokenResponse.associated_user.first_name, - lastName: tokenResponse.associated_user.last_name, - accountOwner: tokenResponse.associated_user.account_owner, - } - : undefined, - }) - - outputDebug( - outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, - ) - - dependencies.presenter.success(store, tokenResponse.associated_user?.email) -} diff --git a/packages/cli/src/cli/services/store/auth/callback.test.ts b/packages/cli/src/cli/services/store/auth/callback.test.ts new file mode 100644 index 00000000000..2cdb74427c7 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.test.ts @@ -0,0 +1,189 @@ +import {createServer} from 'http' +import {describe, expect, test} from 'vitest' +import {waitForStoreAuthCode} from './callback.js' + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Expected an ephemeral port.')) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve(address.port) + }) + }) + }) +} + +function callbackParams(options?: { + code?: string + shop?: string + state?: string + error?: string +}): URLSearchParams { + const params = new URLSearchParams() + params.set('shop', options?.shop ?? 'shop.myshopify.com') + params.set('state', options?.state ?? 'state-123') + + if (options?.code) params.set('code', options.code) + if (options?.error) params.set('error', options.error) + if (!options?.code && !options?.error) params.set('code', 'abc123') + + return params +} + +describe('store auth callback server', () => { + test('waitForStoreAuthCode resolves after a valid callback', async () => { + const port = await getAvailablePort() + const params = callbackParams() + const onListening = async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(200) + await response.text() + } + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening, + }), + ).resolves.toBe('abc123') + }) + + test('waitForStoreAuthCode rejects when callback state does not match', async () => { + const port = await getAvailablePort() + const params = callbackParams({state: 'wrong-state'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback state does not match the original request.') + }) + + test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { + const port = await getAvailablePort() + const params = callbackParams({shop: 'other-shop.myshopify.com'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toMatchObject({ + message: 'OAuth callback store does not match the requested store.', + tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', + nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], + }) + }) + + test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { + const port = await getAvailablePort() + const params = callbackParams({error: 'access_denied'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('Shopify returned an OAuth error: access_denied') + }) + + test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { + const port = await getAvailablePort() + const params = callbackParams() + params.delete('code') + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback did not include an authorization code.') + }) + + test('waitForStoreAuthCode rejects when the port is already in use', async () => { + const port = await getAvailablePort() + const server = createServer() + await new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(port, '127.0.0.1', () => resolve()) + }) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + }), + ).rejects.toThrow(`Port ${port} is already in use.`) + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + }) + + test('waitForStoreAuthCode rejects on timeout', async () => { + const port = await getAvailablePort() + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 25, + }), + ).rejects.toThrow('Timed out waiting for OAuth callback.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/callback.ts b/packages/cli/src/cli/services/store/auth/callback.ts new file mode 100644 index 00000000000..6cf394c5952 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.ts @@ -0,0 +1,185 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {timingSafeEqual} from 'crypto' +import {createServer} from 'http' +import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js' +import {retryStoreAuthWithPermanentDomainError} from './recovery.js' + +export interface WaitForAuthCodeOptions { + store: string + state: string + port: number + timeoutMs?: number + onListening?: () => void | Promise +} + +function renderAuthCallbackPage(title: string, message: string): string { + const safeTitle = title + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + const safeMessage = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + + return ` + + + + + ${safeTitle} + + +
+
+

${safeTitle}

+

${safeMessage}

+
+
+ +` +} + +export async function waitForStoreAuthCode({ + store, + state, + port, + timeoutMs = 5 * 60 * 1000, + onListening, +}: WaitForAuthCodeOptions): Promise { + const normalizedStore = normalizeStoreFqdn(store) + + return new Promise((resolve, reject) => { + let settled = false + let isListening = false + + const timeout = setTimeout(() => { + settleWithError(new AbortError('Timed out waiting for OAuth callback.')) + }, timeoutMs) + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + + if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { + res.statusCode = 404 + res.end('Not found') + return + } + + const {searchParams} = requestUrl + + const fail = (error: AbortError | string, tryMessage?: string) => { + const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error + + res.statusCode = 400 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settleWithError(abortError)) + res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) + } + + const returnedStore = searchParams.get('shop') + outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) + + if (!returnedStore) { + fail('OAuth callback store does not match the requested store.') + return + } + + const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) + if (normalizedReturnedStore !== normalizedStore) { + fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) + return + } + + const returnedState = searchParams.get('state') + if (!returnedState || !constantTimeEqual(returnedState, state)) { + fail('OAuth callback state does not match the original request.') + return + } + + const error = searchParams.get('error') + if (error) { + fail(`Shopify returned an OAuth error: ${error}`) + return + } + + const code = searchParams.get('code') + if (!code) { + fail('OAuth callback did not include an authorization code.') + return + } + + outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settle(() => resolve(code))) + res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) + }) + + const settle = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + + const finalize = () => { + callback() + } + + if (!isListening) { + finalize() + return + } + + server.close(() => { + isListening = false + finalize() + }) + server.closeIdleConnections?.() + } + + const settleWithError = (error: Error) => { + settle(() => reject(error)) + } + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + settleWithError( + new AbortError( + `Port ${port} is already in use.`, + `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, + ), + ) + return + } + + settleWithError(error) + }) + + server.listen(port, '127.0.0.1', async () => { + isListening = true + outputDebug( + outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, + ) + + if (!onListening) return + + try { + await onListening() + } catch (error) { + settleWithError(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} diff --git a/packages/cli/src/cli/services/store/auth-config.ts b/packages/cli/src/cli/services/store/auth/config.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-config.ts rename to packages/cli/src/cli/services/store/auth/config.ts diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts new file mode 100644 index 00000000000..ac53c16f13f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts @@ -0,0 +1,164 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {resolveExistingStoreAuthScopes} from './existing-scopes.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {getCurrentStoredStoreAppSession} from './session-store.js' + +vi.mock('./session-store.js') +vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) + +describe('resolveExistingStoreAuthScopes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + afterEach(() => { + vi.restoreAllMocks() + mockAndCaptureOutput().clear() + }) + + test('returns no scopes when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'fresh-token', + responseOptions: {handleErrors: false}, + }) + }) + + test('falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('falls back to locally stored scopes when access scopes request fails', async () => { + const output = mockAndCaptureOutput() + + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + const scopeLookupError = new Error('GraphQL Error (Code: 401)') + Object.assign(scopeLookupError, { + response: { + status: 401, + errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', + }, + request: { + query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', + }, + }) + vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') + expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') + }) + + test('falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.ts new file mode 100644 index 00000000000..0dcc398e1be --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.ts @@ -0,0 +1,54 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {getCurrentStoredStoreAppSession} from './session-store.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {fetchCurrentStoreAuthScopes} from './token-client.js' + +export interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +function truncateDebugMessage(message: string, length = 300): string { + return message.slice(0, length) +} + +function formatStoreScopeLookupError(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as {response?: {status?: number; errors?: unknown}}).response + const status = response?.status + const details = response?.errors + + if (typeof status === 'number') { + const summary = typeof details === 'string' ? details : JSON.stringify(details) + return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) + } + } + + return truncateDebugMessage(error instanceof Error ? error.message : String(error)) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getCurrentStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes({ + store: usableSession.store, + accessToken: usableSession.accessToken, + }) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts similarity index 50% rename from packages/cli/src/cli/services/store/auth.test.ts rename to packages/cli/src/cli/services/store/auth/index.test.ts index 0ae4fe7c94e..2a4323cb4e4 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -1,64 +1,12 @@ -import {createServer} from 'http' -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import { - authenticateStoreWithApp, - buildStoreAuthUrl, - parseStoreAuthScopes, - generateCodeVerifier, - computeCodeChallenge, - exchangeStoreAuthCodeForToken, - waitForStoreAuthCode, -} from './auth.js' -import {setStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {fetch} from '@shopify/cli-kit/node/http' - -vi.mock('./session.js') -vi.mock('@shopify/cli-kit/node/http') +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {authenticateStoreWithApp} from './index.js' +import {setStoredStoreAppSession} from './session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer() - - server.on('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { - reject(new Error('Expected an ephemeral port.')) - return - } - - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve(address.port) - }) - }) - }) -} - -function callbackParams(options?: { - code?: string - shop?: string - state?: string - error?: string -}): URLSearchParams { - const params = new URLSearchParams() - params.set('shop', options?.shop ?? 'shop.myshopify.com') - params.set('state', options?.state ?? 'state-123') - - if (options?.code) params.set('code', options.code) - if (options?.error) params.set('error', options.error) - if (!options?.code && !options?.error) params.set('code', 'abc123') - - return params -} - describe('store auth service', () => { beforeEach(() => { vi.clearAllMocks() @@ -68,240 +16,186 @@ describe('store auth service', () => { vi.restoreAllMocks() }) - test('generateCodeVerifier produces a base64url string of 43 chars', () => { - const verifier = generateCodeVerifier() - expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) - }) - - test('generateCodeVerifier produces unique values', () => { - const a = generateCodeVerifier() - const b = generateCodeVerifier() - expect(a).not.toBe(b) - }) - - test('computeCodeChallenge produces a deterministic S256 hash', () => { - const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' - const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' - expect(computeCodeChallenge(verifier)).toBe(expected) - }) + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) - test('parseStoreAuthScopes splits and deduplicates scopes', () => { - expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ - 'read_products', - 'write_products', - ]) - }) + const result = await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) - test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { - const url = new URL( - buildStoreAuthUrl({ + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) + expect(presenter.manualAuthUrl).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ store: 'shop.myshopify.com', - scopes: ['read_products', 'write_products'], - state: 'state-123', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - codeChallenge: 'test-challenge-value', + userId: '42', + scopes: ['read_products'], + hasRefreshToken: true, + associatedUser: expect.objectContaining({email: 'test@example.com'}), }), ) + expect(presenter.success).toHaveBeenCalledWith(result) - expect(url.hostname).toBe('shop.myshopify.com') - expect(url.pathname).toBe('/admin/oauth/authorize') - expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(url.searchParams.get('scope')).toBe('read_products,write_products') - expect(url.searchParams.get('state')).toBe('state-123') - expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') - expect(url.searchParams.get('response_type')).toBe('code') - expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') - expect(url.searchParams.get('code_challenge_method')).toBe('S256') - expect(url.searchParams.get('grant_options[]')).toBeNull() + const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] + expect(storedSession.store).toBe('shop.myshopify.com') + expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(storedSession.userId).toBe('42') + expect(storedSession.accessToken).toBe('token') + expect(storedSession.refreshToken).toBe('refresh-token') + expect(storedSession.scopes).toEqual(['read_products']) + expect(storedSession.expiresAt).toBeDefined() + expect(storedSession.associatedUser).toEqual({ + id: 42, + email: 'test@example.com', + firstName: undefined, + lastName: undefined, + accountOwner: undefined, + }) }) - test('waitForStoreAuthCode resolves after a valid callback', async () => { - const port = await getAvailablePort() - const params = callbackParams() - const onListening = vi.fn(async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(200) - await response.text() + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' }) - await expect( - waitForStoreAuthCode({ + await authenticateStoreWithApp( + { store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening, - }), - ).resolves.toBe('abc123') - - expect(onListening).toHaveBeenCalledOnce() - }) - - test('waitForStoreAuthCode rejects when callback state does not match', async () => { - const port = await getAvailablePort() - const params = callbackParams({state: 'wrong-state'}) + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_customers'], authoritative: true}), + presenter, + }, + ) - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('OAuth callback state does not match the original request.') + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') }) - test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { - const port = await getAvailablePort() - const params = callbackParams({shop: 'other-shop.myshopify.com'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toMatchObject({ - message: 'OAuth callback store does not match the requested store.', - tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', - nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' }) - }) - - test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { - const port = await getAvailablePort() - const params = callbackParams({error: 'access_denied'}) - await expect( - waitForStoreAuthCode({ + await authenticateStoreWithApp( + { store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('Shopify returned an OAuth error: access_denied') - }) - - test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { - const port = await getAvailablePort() - const params = callbackParams() - params.delete('code') + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) - await expect( - waitForStoreAuthCode({ + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, + scopes: ['read_orders', 'read_products'], }), - ).rejects.toThrow('OAuth callback did not include an authorization code.') + ) }) - test('waitForStoreAuthCode rejects when the port is already in use', async () => { - const port = await getAvailablePort() - const server = createServer() - await new Promise((resolve, reject) => { - server.on('error', reject) - server.listen(port, '127.0.0.1', () => resolve()) - }) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - }), - ).rejects.toThrow(`Port ${port} is already in use.`) - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve() - }) + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' }) - }) - test('waitForStoreAuthCode rejects on timeout', async () => { - const port = await getAvailablePort() - - await expect( - waitForStoreAuthCode({ + await authenticateStoreWithApp( + { store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 25, - }), - ).rejects.toThrow('Timed out waiting for OAuth callback.') - }) - - test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ access_token: 'token', scope: 'read_products', expires_in: 86400, - refresh_token: 'refresh-token', associated_user: {id: 42, email: 'test@example.com'}, }), - ), - } as any) - - const response = await exchangeStoreAuthCodeForToken({ - store: 'shop.myshopify.com', - code: 'abc123', - codeVerifier: 'test-verifier', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - }) - - expect(response.access_token).toBe('token') - expect(response.refresh_token).toBe('refresh-token') - expect(response.expires_in).toBe(86400) + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) - expect(fetch).toHaveBeenCalledWith( - 'https://shop.myshopify.com/admin/oauth/access_token', + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"code_verifier":"test-verifier"'), + store: 'shop.myshopify.com', + scopes: ['read_products'], }), ) - - const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) - expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(sentBody.code).toBe('abc123') - expect(sentBody.code_verifier).toBe('test-verifier') - expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') - expect(sentBody.client_secret).toBeUndefined() }) - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { openingBrowser: vi.fn(), @@ -323,35 +217,23 @@ describe('store auth service', () => { waitForStoreAuthCode: waitForStoreAuthCodeMock, exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ access_token: 'token', - scope: 'read_products', + scope: 'write_products', expires_in: 86400, - refresh_token: 'refresh-token', associated_user: {id: 42, email: 'test@example.com'}, }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), presenter, }, ) - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) - expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') - - const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] - expect(storedSession.store).toBe('shop.myshopify.com') - expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(storedSession.userId).toBe('42') - expect(storedSession.accessToken).toBe('token') - expect(storedSession.refreshToken).toBe('refresh-token') - expect(storedSession.scopes).toEqual(['read_products']) - expect(storedSession.expiresAt).toBeDefined() - expect(storedSession.associatedUser).toEqual({ - id: 42, - email: 'test@example.com', - firstName: undefined, - lastName: undefined, - accountOwner: undefined, - }) + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) }) test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { @@ -366,7 +248,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -388,7 +270,7 @@ describe('store auth service', () => { expect(presenter.manualAuthUrl).toHaveBeenCalledWith( expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(presenter.success).toHaveBeenCalledWith(result) }) test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts new file mode 100644 index 00000000000..eabd3bf3b37 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -0,0 +1,120 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {openURL} from '@shopify/cli-kit/node/system' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {setStoredStoreAppSession} from './session-store.js' +import {exchangeStoreAuthCodeForToken} from './token-client.js' +import {waitForStoreAuthCode} from './callback.js' +import {createPkceBootstrap} from './pkce.js' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' +import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' +import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' + +interface StoreAuthInput { + store: string + scopes: string +} + +interface StoreAuthDependencies { + openURL: typeof openURL + waitForStoreAuthCode: typeof waitForStoreAuthCode + exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes: (store: string) => Promise + presenter: StoreAuthPresenter +} + +const defaultStoreAuthDependencies: StoreAuthDependencies = { + openURL, + waitForStoreAuthCode, + exchangeStoreAuthCodeForToken, + resolveExistingScopes: resolveExistingStoreAuthScopes, + presenter: createStoreAuthPresenter('text'), +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({ + store, + scopes, + exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, + }) + const { + authorization: {authorizationUrl}, + } = bootstrap + + resolvedDependencies.presenter.openingBrowser() + + const code = await resolvedDependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + }, + }) + const tokenResponse = await bootstrap.exchangeCodeForToken(code) + + const userId = tokenResponse.associated_user?.id?.toString() + if (!userId) { + throw new AbortError('Shopify did not return associated user information for the online access token.') + } + + const now = Date.now() + const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined + + const result: StoreAuthResult = { + store, + userId, + scopes: resolveGrantedScopes(tokenResponse, validationScopes), + acquiredAt: new Date(now).toISOString(), + expiresAt, + refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in + ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() + : undefined, + hasRefreshToken: !!tokenResponse.refresh_token, + associatedUser: tokenResponse.associated_user + ? { + id: tokenResponse.associated_user.id, + email: tokenResponse.associated_user.email, + firstName: tokenResponse.associated_user.first_name, + lastName: tokenResponse.associated_user.last_name, + accountOwner: tokenResponse.associated_user.account_owner, + } + : undefined, + } + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: result.scopes, + acquiredAt: result.acquiredAt, + expiresAt, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + associatedUser: result.associatedUser, + }) + + outputDebug( + outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, + ) + + resolvedDependencies.presenter.success(result) + return result +} diff --git a/packages/cli/src/cli/services/store/auth/pkce.test.ts b/packages/cli/src/cli/services/store/auth/pkce.test.ts new file mode 100644 index 00000000000..d096446c94d --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, test} from 'vitest' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {buildStoreAuthUrl, computeCodeChallenge, generateCodeVerifier} from './pkce.js' + +describe('store auth PKCE helpers', () => { + test('generateCodeVerifier produces a base64url string of 43 chars', () => { + const verifier = generateCodeVerifier() + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + test('generateCodeVerifier produces unique values', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) + + test('computeCodeChallenge produces a deterministic S256 hash', () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + expect(computeCodeChallenge(verifier)).toBe(expected) + }) + + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { + const url = new URL( + buildStoreAuthUrl({ + store: 'shop.myshopify.com', + scopes: ['read_products', 'write_products'], + state: 'state-123', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + codeChallenge: 'test-challenge-value', + }), + ) + + expect(url.hostname).toBe('shop.myshopify.com') + expect(url.pathname).toBe('/admin/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(url.searchParams.get('scope')).toBe('read_products,write_products') + expect(url.searchParams.get('state')).toBe('state-123') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('grant_options[]')).toBeNull() + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/pkce.ts b/packages/cli/src/cli/services/store/auth/pkce.ts new file mode 100644 index 00000000000..c9944eee30f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.ts @@ -0,0 +1,90 @@ +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {createHash, randomBytes} from 'crypto' +import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, storeAuthRedirectUri} from './config.js' +import type {StoreTokenResponse} from './token-client.js' +import type {WaitForAuthCodeOptions} from './callback.js' + +interface StoreAuthorizationContext { + store: string + scopes: string[] + state: string + port: number + redirectUri: string + authorizationUrl: string + codeVerifier: string +} + +interface StoreAuthBootstrap { + authorization: StoreAuthorizationContext + waitForAuthCodeOptions: WaitForAuthCodeOptions + exchangeCodeForToken: (code: string) => Promise +} + +export function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +export function computeCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +export function buildStoreAuthUrl(options: { + store: string + scopes: string[] + state: string + redirectUri: string + codeChallenge: string +}): string { + const params = new URLSearchParams() + params.set('client_id', STORE_AUTH_APP_CLIENT_ID) + params.set('scope', options.scopes.join(',')) + params.set('redirect_uri', options.redirectUri) + params.set('state', options.state) + params.set('response_type', 'code') + params.set('code_challenge', options.codeChallenge) + params.set('code_challenge_method', 'S256') + + return `https://${options.store}/admin/oauth/authorize?${params.toString()}` +} + +export function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: (options: { + store: string + code: string + codeVerifier: string + redirectUri: string + }) => Promise +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options + const port = DEFAULT_STORE_AUTH_PORT + const state = randomUUID() + const redirectUri = storeAuthRedirectUri(port) + const codeVerifier = generateCodeVerifier() + const codeChallenge = computeCodeChallenge(codeVerifier) + const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) + + outputDebug( + outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, + ) + + return { + authorization: { + store, + scopes, + state, + port, + redirectUri, + authorizationUrl, + codeVerifier, + }, + waitForAuthCodeOptions: { + store, + state, + port, + }, + exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), + } +} diff --git a/packages/cli/src/cli/services/store/auth-recovery.ts b/packages/cli/src/cli/services/store/auth/recovery.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-recovery.ts rename to packages/cli/src/cli/services/store/auth/recovery.ts diff --git a/packages/cli/src/cli/services/store/auth/result.test.ts b/packages/cli/src/cli/services/store/auth/result.test.ts new file mode 100644 index 00000000000..83c1e9e9280 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.test.ts @@ -0,0 +1,102 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {createStoreAuthPresenter} from './result.js' + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +describe('store auth presenter', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + + test('renders human success output in text mode', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('text') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.completed()).toContain('Logged in.') + expect(output.completed()).toContain('Authenticated as merchant@example.com against shop.myshopify.com.') + expect(output.info()).toContain("shopify store execute --store shop.myshopify.com --query 'query { shop { name id } }'") + expect(output.output()).not.toContain('"store": "shop.myshopify.com"') + }) + + test('writes json success output through the result channel', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('json') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.completed()).not.toContain('Authenticated') + expect(output.info()).not.toContain('shopify store execute') + }) + + test('writes browser guidance to stderr and json success to stdout', () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + const presenter = createStoreAuthPresenter('json') + + try { + presenter.openingBrowser() + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + } finally { + streams.restore() + } + + expect(streams.stderr()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(streams.stderr()).toContain('Browser did not open automatically. Open this URL manually:') + expect(streams.stderr()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') + expect(streams.stdout()).not.toContain('Authenticated') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/result.ts b/packages/cli/src/cli/services/store/auth/result.ts new file mode 100644 index 00000000000..4b7eec807e2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.ts @@ -0,0 +1,71 @@ +import {outputCompleted, outputInfo, outputResult, outputToken, outputContent} from '@shopify/cli-kit/node/output' + +export interface StoreAuthResult { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +type StoreAuthOutputFormat = 'text' | 'json' + +export interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (result: StoreAuthResult) => void +} + +function serializeStoreAuthResult(result: StoreAuthResult): string { + return JSON.stringify(result, null, 2) +} + +function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[]; info: string[]} { + const displayName = result.associatedUser?.email ? ` as ${result.associatedUser.email}` : '' + + return { + completed: ['Logged in.', `Authenticated${displayName} against ${result.store}.`], + info: ['', 'To verify that authentication worked, run:', `shopify store execute --store ${result.store} --query 'query { shop { name id } }'`], + } +} + +function displayStoreAuthOpeningBrowser(): void { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') +} + +function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') +} + +function displayStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutputFormat = 'text'): void { + if (format === 'json') { + outputResult(serializeStoreAuthResult(result)) + return + } + + const text = buildStoreAuthSuccessText(result) + text.completed.forEach((line) => outputCompleted(line)) + text.info.forEach((line) => outputInfo(line)) +} + +export function createStoreAuthPresenter(format: StoreAuthOutputFormat = 'text'): StoreAuthPresenter { + return { + openingBrowser: displayStoreAuthOpeningBrowser, + manualAuthUrl: displayStoreAuthManualAuthUrl, + success(result: StoreAuthResult) { + displayStoreAuthResult(result, format) + }, + } +} diff --git a/packages/cli/src/cli/services/store/auth/scopes.test.ts b/packages/cli/src/cli/services/store/auth/scopes.test.ts new file mode 100644 index 00000000000..6df7fa07512 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' + +describe('store auth scope helpers', () => { + test('parseStoreAuthScopes splits and deduplicates scopes', () => { + expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ + 'read_products', + 'write_products', + ]) + }) + + test('mergeRequestedAndStoredScopes avoids redundant reads already implied by existing writes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['write_products'])).toEqual(['write_products']) + }) + + test('mergeRequestedAndStoredScopes adds newly requested scopes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['read_orders'])).toEqual(['read_orders', 'read_products']) + }) + + test('resolveGrantedScopes accepts compressed write scopes that imply requested reads', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + scope: 'write_products', + }, + ['read_products', 'write_products'], + ), + ).toEqual(['write_products']) + }) + + test('resolveGrantedScopes falls back to requested scopes when Shopify omits scope', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + }, + ['read_products'], + ), + ).toEqual(['read_products']) + }) + + test('resolveGrantedScopes rejects when required scopes are missing', () => { + expect(() => + resolveGrantedScopes( + { + access_token: 'token', + scope: 'read_products', + }, + ['read_products', 'write_products'], + ), + ).toThrow('Shopify granted fewer scopes than were requested.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/scopes.ts b/packages/cli/src/cli/services/store/auth/scopes.ts new file mode 100644 index 00000000000..efe8a2b5913 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.ts @@ -0,0 +1,70 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug} from '@shopify/cli-kit/node/output' +import type {StoreTokenResponse} from './token-client.js' + +export function parseStoreAuthScopes(input: string): string[] { + const scopes = input + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean) + + if (scopes.length === 0) { + throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') + } + + return [...new Set(scopes)] +} + +function expandImpliedStoreScopes(scopes: string[]): Set { + const expandedScopes = new Set(scopes) + + for (const scope of scopes) { + const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) + if (matches) { + expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) + } + } + + return expandedScopes +} + +export function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +export function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { + if (!tokenResponse.scope) { + outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) + return requestedScopes + } + + const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) + const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) + const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) + + if (missingScopes.length > 0) { + throw new AbortError( + 'Shopify granted fewer scopes than were requested.', + `Missing scopes: ${missingScopes.join(', ')}.`, + [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + ) + } + + return grantedScopes +} diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts new file mode 100644 index 00000000000..c1d3c652870 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts @@ -0,0 +1,179 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' +import { + isSessionExpired, + loadStoredStoreSession, +} from './session-lifecycle.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, + type StoredStoreAppSession, +} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') +vi.mock('./token-client.js') + +function buildSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + ...overrides, + } +} + +describe('isSessionExpired', () => { + test('returns false when expiresAt is not set', () => { + expect(isSessionExpired(buildSession())).toBe(false) + }) + + test('returns false when token is still valid', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + }) + + test('returns true when token is expired', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) + }) + + test('returns true within the 4-minute expiry margin', () => { + const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + }) + + test('returns false just outside the 4-minute expiry margin', () => { + const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + }) + + test('returns true when expiresAt is invalid', () => { + expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + }) +}) + +describe('loadStoredStoreSession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('throws when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No stored app authentication found for shop.myshopify.com.', + }) + }) + + test('returns the current stored session when it is still valid', async () => { + const session = buildSession({expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + + await expect(loadStoredStoreSession('shop.myshopify.com')).resolves.toEqual(session) + expect(refreshStoreAccessToken).not.toHaveBeenCalled() + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when an expired session has no refresh token', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue( + buildSession({refreshToken: undefined, expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No refresh token stored for shop.myshopify.com.', + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('refreshes expired sessions and persists the refreshed identity-preserving session', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshStoreAccessToken).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + refreshToken: 'refresh-token', + }) + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: session.store, + clientId: session.clientId, + userId: session.userId, + scopes: session.scopes, + associatedUser: session.associatedUser, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + }), + ) + expect(refreshed).toEqual(expect.objectContaining({accessToken: 'fresh-token', userId: '42'})) + }) + + test('preserves existing optional refresh fields when Shopify omits them', async () => { + const session = buildSession({ + expiresAt: new Date(Date.now() - 60 * 1000).toISOString(), + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + }) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshed.refreshToken).toBe('refresh-token') + expect(refreshed.refreshTokenExpiresAt).toBe('2026-04-03T00:00:00.000Z') + expect(refreshed.expiresAt).toBe(session.expiresAt) + }) + + test('clears only the current stored auth and throws re-auth when refresh fails', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh failed for shop.myshopify.com (HTTP 401).'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh failed for shop.myshopify.com (HTTP 401).', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws on malformed refresh JSON', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue(new AbortError('Received an invalid refresh response from Shopify.')) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toThrow('Received an invalid refresh response') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws re-auth when refresh returns an invalid response', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh returned an invalid response for shop.myshopify.com.'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh returned an invalid response for shop.myshopify.com.', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts new file mode 100644 index 00000000000..309e6db71a2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts @@ -0,0 +1,105 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken} from './config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './recovery.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, +} from './session-store.js' +import type {StoredStoreAppSession} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' + +const EXPIRY_MARGIN_MS = 4 * 60 * 1000 + +export function isSessionExpired(session: StoredStoreAppSession): boolean { + if (!session.expiresAt) return false + + const expiresAtMs = new Date(session.expiresAt).getTime() + if (Number.isNaN(expiresAtMs)) return true + + return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() +} + +function buildRefreshedStoredSession( + session: StoredStoreAppSession, + refresh: { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number + }, +): StoredStoreAppSession { + const now = Date.now() + const expiresAt = refresh.expiresIn ? new Date(now + refresh.expiresIn * 1000).toISOString() : session.expiresAt + + return { + ...session, + accessToken: refresh.accessToken, + refreshToken: refresh.refreshToken ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: refresh.refreshTokenExpiresIn + ? new Date(now + refresh.refreshTokenExpiresIn * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getCurrentStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (!isSessionExpired(session)) { + return session + } + + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const previousAccessToken = session.accessToken + + let refreshed + try { + refreshed = await refreshStoreAccessToken({ + store: session.store, + refreshToken: session.refreshToken, + }) + } catch (error) { + clearStoredStoreAppSession(session.store, session.userId) + + if (error instanceof AbortError && error.message.startsWith(`Token refresh failed for ${session.store} (HTTP `)) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === `Token refresh returned an invalid response for ${session.store}.`) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === 'Received an invalid refresh response from Shopify.') { + throw error + } + + throw error + } + + session = buildRefreshedStoredSession(session, refreshed) + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(previousAccessToken))} → ${outputToken.raw(maskToken(session.accessToken))}, new expiry ${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(session) + return session +} diff --git a/packages/cli/src/cli/services/store/session.test.ts b/packages/cli/src/cli/services/store/auth/session-store.test.ts similarity index 50% rename from packages/cli/src/cli/services/store/session.test.ts rename to packages/cli/src/cli/services/store/auth/session-store.test.ts index 373fb0d4286..db501895604 100644 --- a/packages/cli/src/cli/services/store/session.test.ts +++ b/packages/cli/src/cli/services/store/auth/session-store.test.ts @@ -1,13 +1,12 @@ import {describe, test, expect} from 'vitest' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './auth-config.js' +import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' import { clearStoredStoreAppSession, - getStoredStoreAppSession, + getCurrentStoredStoreAppSession, setStoredStoreAppSession, - isSessionExpired, type StoredStoreAppSession, -} from './session.js' +} from './session-store.js' function inMemoryStorage() { const values = new Map() @@ -43,7 +42,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) test('keeps multiple user sessions per store and returns the current one', () => { @@ -54,7 +53,7 @@ describe('store session storage', () => { setStoredStoreAppSession(firstSession, storage as any) setStoredStoreAppSession(secondSession, storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) }) test('clears all stored sessions for a store', () => { @@ -63,7 +62,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) clearStoredStoreAppSession('shop.myshopify.com', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() }) test('clears only the specified user session and preserves the rest of the bucket', () => { @@ -75,7 +74,7 @@ describe('store session storage', () => { setStoredStoreAppSession(secondSession, storage as any) clearStoredStoreAppSession('shop.myshopify.com', '84', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) }) test('returns undefined and clears the bucket when the current user session is missing', () => { @@ -87,7 +86,7 @@ describe('store session storage', () => { }, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) @@ -98,37 +97,73 @@ describe('store session storage', () => { sessionsByUserId: null, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) -}) -describe('isSessionExpired', () => { - test('returns false when expiresAt is not set', () => { - expect(isSessionExpired(buildSession())).toBe(false) - }) + test('returns undefined and clears the bucket when the current stored session is malformed', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': {userId: '42'}, + }, + }) - test('returns false when token is still valid', () => { - const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) - test('returns true when token is expired', () => { - const past = new Date(Date.now() - 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) - }) + test('drops malformed optional fields from a stored session instead of rejecting the whole session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': { + ...buildSession(), + refreshToken: 123, + expiresAt: 456, + refreshTokenExpiresAt: true, + associatedUser: { + id: 42, + email: 123, + firstName: 'Merchant', + lastName: false, + accountOwner: 'yes', + }, + }, + }, + }) - test('returns true within the 4-minute expiry margin', () => { - const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual({ + ...buildSession(), + associatedUser: { + id: 42, + firstName: 'Merchant', + }, + }) }) - test('returns false just outside the 4-minute expiry margin', () => { - const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + test('overwrites a malformed bucket when writing a new session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + setStoredStoreAppSession(buildSession(), storage as any) + + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) - test('returns true when expiresAt is invalid', () => { - expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + test('clears malformed buckets without throwing when removing a specific user', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + expect(() => clearStoredStoreAppSession('shop.myshopify.com', '42', storage as any)).not.toThrow() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) }) diff --git a/packages/cli/src/cli/services/store/auth/session-store.ts b/packages/cli/src/cli/services/store/auth/session-store.ts new file mode 100644 index 00000000000..868db8487cc --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-store.ts @@ -0,0 +1,192 @@ +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {storeAuthSessionKey} from './config.js' + +export interface StoredStoreAppSession { + store: string + clientId: string + userId: string + accessToken: string + refreshToken?: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +interface StoredStoreAppSessionBucket { + currentUserId: string + sessionsByUserId: {[userId: string]: StoredStoreAppSession} +} + +interface StoreSessionSchema { + [key: string]: StoredStoreAppSessionBucket +} + +let _storeSessionStorage: LocalStorage | undefined + +function storeSessionStorage() { + _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) + return _storeSessionStorage +} + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associatedUser'] | undefined { + if (!value || typeof value !== 'object') return undefined + + const associatedUser = value as Record + if (typeof associatedUser.id !== 'number') return undefined + + return { + id: associatedUser.id, + ...(isString(associatedUser.email) ? {email: associatedUser.email} : {}), + ...(isString(associatedUser.firstName) ? {firstName: associatedUser.firstName} : {}), + ...(isString(associatedUser.lastName) ? {lastName: associatedUser.lastName} : {}), + ...(typeof associatedUser.accountOwner === 'boolean' ? {accountOwner: associatedUser.accountOwner} : {}), + } +} + +function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined { + if (!value || typeof value !== 'object') return undefined + + const session = value as Record + if ( + !isString(session.store) || + !isString(session.clientId) || + !isString(session.userId) || + !isString(session.accessToken) || + !Array.isArray(session.scopes) || + !session.scopes.every(isString) || + !isString(session.acquiredAt) + ) { + return undefined + } + + return { + store: session.store, + clientId: session.clientId, + userId: session.userId, + accessToken: session.accessToken, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + ...(isString(session.refreshToken) ? {refreshToken: session.refreshToken} : {}), + ...(isString(session.expiresAt) ? {expiresAt: session.expiresAt} : {}), + ...(isString(session.refreshTokenExpiresAt) ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}), + ...(sanitizeAssociatedUser(session.associatedUser) ? {associatedUser: sanitizeAssociatedUser(session.associatedUser)} : {}), + } +} + +function readStoredStoreAppSessionBucket( + store: string, + storage: LocalStorage, +): StoredStoreAppSessionBucket | undefined { + const key = storeAuthSessionKey(store) + const storedBucket = storage.get(key) + if (!storedBucket || typeof storedBucket !== 'object') return undefined + + const {sessionsByUserId, currentUserId} = storedBucket as Partial + if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || Array.isArray(sessionsByUserId) || typeof currentUserId !== 'string') { + storage.delete(key) + return undefined + } + + const sanitizedSessionsByUserId = Object.fromEntries( + Object.entries(sessionsByUserId).flatMap(([userId, session]) => { + const sanitizedSession = sanitizeStoredStoreAppSession(session) + return sanitizedSession ? [[userId, sanitizedSession]] : [] + }), + ) + + if (Object.keys(sanitizedSessionsByUserId).length !== Object.keys(sessionsByUserId).length) { + if (sanitizedSessionsByUserId[currentUserId]) { + storage.set(key, { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + }) + } else { + storage.delete(key) + return undefined + } + } + + return { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + } +} + +export function getCurrentStoredStoreAppSession( + store: string, + storage: LocalStorage = storeSessionStorage(), +): StoredStoreAppSession | undefined { + const bucket = readStoredStoreAppSessionBucket(store, storage) + if (!bucket) return undefined + + const session = bucket.sessionsByUserId[bucket.currentUserId] + if (!session) { + storage.delete(storeAuthSessionKey(store)) + return undefined + } + + return session +} + +export function setStoredStoreAppSession( + session: StoredStoreAppSession, + storage: LocalStorage = storeSessionStorage(), +): void { + const key = storeAuthSessionKey(session.store) + const existingBucket = readStoredStoreAppSessionBucket(session.store, storage) + + const nextBucket: StoredStoreAppSessionBucket = { + currentUserId: session.userId, + sessionsByUserId: { + ...(existingBucket?.sessionsByUserId ?? {}), + [session.userId]: session, + }, + } + + storage.set(key, nextBucket) +} + +export function clearStoredStoreAppSession( + store: string, + userIdOrStorage?: string | LocalStorage, + maybeStorage?: LocalStorage, +): void { + const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined + const storage = + (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() + + const key = storeAuthSessionKey(store) + + if (!userId) { + storage.delete(key) + return + } + + const existingBucket = readStoredStoreAppSessionBucket(store, storage) + if (!existingBucket) return + + const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId + + const remainingUserIds = Object.keys(remainingSessions) + if (remainingUserIds.length === 0) { + storage.delete(key) + return + } + + storage.set(key, { + currentUserId: existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, + sessionsByUserId: remainingSessions, + }) +} diff --git a/packages/cli/src/cli/services/store/auth/token-client.test.ts b/packages/cli/src/cli/services/store/auth/token-client.test.ts new file mode 100644 index 00000000000..eab22b6abd0 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.test.ts @@ -0,0 +1,167 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputDebug} from '@shopify/cli-kit/node/output' +import { + exchangeStoreAuthCodeForToken, + fetchCurrentStoreAuthScopes, + refreshStoreAccessToken, +} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return { + ...actual, + outputDebug: vi.fn(), + } +}) + +describe('token client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + ), + } as any) + + const response = await exchangeStoreAuthCodeForToken({ + store: 'shop.myshopify.com', + code: 'abc123', + codeVerifier: 'test-verifier', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + }) + + expect(response.access_token).toBe('token') + expect(response.refresh_token).toBe('refresh-token') + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"code_verifier":"test-verifier"'), + }), + ) + + const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) + expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(sentBody.code).toBe('abc123') + expect(sentBody.code_verifier).toBe('test-verifier') + expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') + }) + + test('refreshStoreAccessToken sends refresh params and returns normalized payload', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'fresh-refresh-token', + expires_in: 3600, + refresh_token_expires_in: 7200, + }), + ), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).resolves.toEqual({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: 'refresh-token', + }), + }), + ) + }) + + test('refreshStoreAccessToken throws on malformed JSON', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('not-json'), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Received an invalid refresh response from Shopify.') + }) + + test('refreshStoreAccessToken throws when access token is missing', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Token refresh returned an invalid response for shop.myshopify.com.') + }) + + test('fetchCurrentStoreAuthScopes returns current scope handles', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_orders'}]}, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).resolves.toEqual(['read_products', 'read_orders']) + + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'token', + responseOptions: {handleErrors: false}, + }) + }) + + test('fetchCurrentStoreAuthScopes throws on GraphQL lookup failure', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error('shopify exploded')) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('shopify exploded') + }) + + test('fetchCurrentStoreAuthScopes throws on invalid response shape', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('Shopify did not return currentAppInstallation.accessScopes.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/token-client.ts b/packages/cli/src/cli/services/store/auth/token-client.ts new file mode 100644 index 00000000000..5931e4de9ae --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.ts @@ -0,0 +1,171 @@ +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './config.js' + +export interface StoreTokenResponse { + access_token: string + token_type?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number + associated_user_scope?: string + associated_user?: { + id: number + first_name?: string + last_name?: string + email?: string + account_owner?: boolean + locale?: string + collaborator?: boolean + email_verified?: boolean + } +} + +interface StoreAccessScopesResponse { + currentAppInstallation?: { + accessScopes?: {handle?: string}[] + } +} + +interface StoreTokenRefreshPayload { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number +} + +function truncateHttpErrorBody(body: string, length = 300): string { + return body.slice(0, length) +} + +export async function exchangeStoreAuthCodeForToken(options: { + store: string + code: string + codeVerifier: string + redirectUri: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + code: options.code, + code_verifier: options.codeVerifier, + redirect_uri: options.redirectUri, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError( + `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, + body || response.statusText, + ) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid token response from Shopify.') + } + + outputDebug( + outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, + ) + + return parsed +} + +export async function refreshStoreAccessToken(options: { + store: string + refreshToken: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing access token for ${outputToken.raw(options.store)} using refresh_token=${outputToken.raw(maskToken(options.refreshToken))}`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: options.refreshToken, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError(`Token refresh failed for ${options.store} (HTTP ${response.status}).`) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!parsed.access_token) { + throw new AbortError(`Token refresh returned an invalid response for ${options.store}.`) + } + + return { + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiresIn: parsed.expires_in, + refreshTokenExpiresIn: parsed.refresh_token_expires_in, + } +} + +const CurrentAppInstallationAccessScopesQuery = `#graphql + query CurrentAppInstallationAccessScopes { + currentAppInstallation { + accessScopes { + handle + } + } + } +` + +export async function fetchCurrentStoreAuthScopes(options: { + store: string + accessToken: string +}): Promise { + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(options.store)} using token ${outputToken.raw(maskToken(options.accessToken))}`, + ) + + const data = await graphqlRequest({ + query: CurrentAppInstallationAccessScopesQuery, + api: 'Admin', + url: adminUrl(options.store, 'unstable'), + token: options.accessToken, + responseOptions: {handleErrors: false}, + }) + + if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { + throw new Error('Shopify did not return currentAppInstallation.accessScopes.') + } + + return data.currentAppInstallation.accessScopes.flatMap((scope) => + typeof scope.handle === 'string' ? [scope.handle] : [], + ) +} diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute-result.test.ts deleted file mode 100644 index 2a370560acf..00000000000 --- a/packages/cli/src/cli/services/store/execute-result.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {writeFile} from '@shopify/cli-kit/node/fs' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' - -vi.mock('@shopify/cli-kit/node/fs') -vi.mock('@shopify/cli-kit/node/ui') - -describe('writeOrOutputStoreExecuteResult', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAndCaptureOutput().clear() - }) - - test('writes results to a file when outputFile is provided', async () => { - await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') - - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) - }) - - test('writes results to stdout when no outputFile is provided', async () => { - const output = mockAndCaptureOutput() - - await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) - - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - expect(output.info()).toContain('Test shop') - }) -}) diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute-result.ts deleted file mode 100644 index dd284a5deae..00000000000 --- a/packages/cli/src/cli/services/store/execute-result.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {writeFile} from '@shopify/cli-kit/node/fs' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderSuccess} from '@shopify/cli-kit/node/ui' - -export async function writeOrOutputStoreExecuteResult(result: unknown, outputFile?: string): Promise { - const resultString = JSON.stringify(result, null, 2) - - if (outputFile) { - await writeFile(outputFile, resultString) - renderSuccess({ - headline: 'Operation succeeded.', - body: `Results written to ${outputFile}`, - }) - } else { - renderSuccess({headline: 'Operation succeeded.'}) - outputResult(resultString) - } -} diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts deleted file mode 100644 index e9ab1276937..00000000000 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import {executeStoreOperation} from './execute.js' -import {getStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' -import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./session.js') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/fs') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - adminUrl: vi.fn(), - } -}) - -describe('executeStoreOperation', () => { - const store = 'shop.myshopify.com' - const session = {token: 'token', storeFqdn: store} - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) - }) - - afterEach(() => { - mockAndCaptureOutput().clear() - }) - - test('executes a query successfully', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - const output = mockAndCaptureOutput() - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - }) - - expect(renderSingleTask).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.anything(), - }), - ) - expect(getStoredStoreAppSession).toHaveBeenCalledWith(store) - expect(fetchApiVersions).toHaveBeenCalledWith(session) - expect(graphqlRequest).toHaveBeenCalledWith({ - query: 'query { shop { name } }', - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', - token: 'token', - variables: undefined, - responseOptions: {handleErrors: false}, - }) - expect(output.info()).toContain('"name": "Test shop"') - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - }) - - test('passes parsed variables when provided inline', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variables: '{"id":"gid://shopify/Shop/1"}', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('reads variables from a file', async () => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(readFile).mockResolvedValue('{"id":"gid://shopify/Shop/1"}' as any) - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variableFile: '/tmp/variables.json', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('throws when variables contain invalid JSON', async () => { - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - variables: '{invalid json}', - }), - ).rejects.toThrow('Invalid JSON') - - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('throws when mutations are not explicitly allowed', async () => { - await expect( - executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - }), - ).rejects.toThrow('Mutations are disabled by default') - - expect(getStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('throws when no stored app session exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('No stored app authentication found') - }) - - test('allows mutations when explicitly enabled', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {productCreate: {product: {id: 'gid://shopify/Product/1'}}}}) - - await executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - allowMutations: true, - }) - - expect(graphqlRequest).toHaveBeenCalled() - }) - - test('uses the specified API version when provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-07/graphql.json') - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - version: '2025-07', - }) - - expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) - }) - - test('writes results to a file when outputFile is provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - outputFile: '/tmp/results.json', - }) - - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) - }) - - test('throws when stored auth is no longer valid', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - status: 401, - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('Stored app authentication for') - }) - - test('throws on GraphQL errors', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - errors: [{message: 'Field does not exist'}], - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { nope }', - }), - ).rejects.toThrow('GraphQL operation failed.') - }) - -}) diff --git a/packages/cli/src/cli/services/store/execute/admin-context.test.ts b/packages/cli/src/cli/services/store/execute/admin-context.test.ts new file mode 100644 index 00000000000..d82ed61de96 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/admin-context.test.ts @@ -0,0 +1,116 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' + +vi.mock('../auth/session-store.js') +vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + } +}) + +describe('prepareAdminStoreGraphQLContext', () => { + const store = 'shop.myshopify.com' + const storedSession = { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + }) + + test('returns the stored admin session, version, and full auth session', async () => { + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'token', + storeFqdn: store, + }) + expect(result).toEqual({ + adminSession: { + token: 'token', + storeFqdn: store, + }, + version: '2025-10', + session: storedSession, + }) + }) + + test('uses the loaded refreshed session for both admin auth and returned context', async () => { + const refreshedSession = { + ...storedSession, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresAt: '2026-04-03T00:00:00.000Z', + } + vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession) + + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'fresh-token', + storeFqdn: store, + }) + expect(result.adminSession.token).toBe('fresh-token') + expect(result.session).toEqual(refreshedSession) + }) + + test('returns the requested API version when provided', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) + + expect(result.version).toBe('2025-07') + }) + + test('allows unstable without validating against fetched versions', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) + + expect(result.version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('clears the current stored auth and prompts re-auth with real scopes when API version lookup gets invalid auth', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue( + new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), + ) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('rethrows unrelated API version lookup failures', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue(new AbortError('upstream exploded')) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when the requested API version is invalid', async () => { + await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( + 'Invalid API version', + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/execute/admin-context.ts b/packages/cli/src/cli/services/store/execute/admin-context.ts new file mode 100644 index 00000000000..dfabaa2eb6f --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/admin-context.ts @@ -0,0 +1,67 @@ +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {AdminSession} from '@shopify/cli-kit/node/session' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' + +export interface AdminStoreGraphQLContext { + adminSession: AdminSession + version: string + session: StoredStoreAppSession +} + +async function resolveApiVersion(options: { + session: StoredStoreAppSession + adminSession: AdminSession + userSpecifiedVersion?: string +}): Promise { + const {session, adminSession, userSpecifiedVersion} = options + + if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion + + let availableVersions + try { + availableVersions = await fetchApiVersions(adminSession) + } catch (error) { + if ( + error instanceof AbortError && + error.message.includes(`Error connecting to your store ${adminSession.storeFqdn}:`) && + /\b(?:401|404)\b/.test(error.message) + ) { + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Stored app authentication for ${session.store} is no longer valid.`, + session.store, + session.scopes.join(','), + ) + } + + throw error + } + + if (!userSpecifiedVersion) { + const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) + return supportedVersions.sort().reverse()[0]! + } + + const versionList = availableVersions.map((version) => version.handle) + if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion + + throw new AbortError(`Invalid API version: ${userSpecifiedVersion}`, `Allowed versions: ${versionList.join(', ')}`) +} + +export async function prepareAdminStoreGraphQLContext(input: { + store: string + userSpecifiedVersion?: string +}): Promise { + const session = await loadStoredStoreSession(input.store) + const adminSession = { + token: session.accessToken, + storeFqdn: session.store, + } + const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) + + return {adminSession, version, session} +} diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts similarity index 68% rename from packages/cli/src/cli/services/store/admin-graphql-transport.test.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.test.ts index a0d03fc1051..0d39950d7aa 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts @@ -2,11 +2,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './session.js' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {prepareStoreExecuteRequest} from './request.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./session.js') +vi.mock('../auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -19,7 +20,18 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('runAdminStoreGraphQLOperation', () => { const store = 'shop.myshopify.com' - const adminSession = {token: 'token', storeFqdn: store} + const context = { + adminSession: {token: 'token', storeFqdn: store}, + version: '2025-10', + session: { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + } beforeEach(() => { vi.clearAllMocks() @@ -31,13 +43,7 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - const result = await runAdminStoreGraphQLOperation({ - store, - adminSession, - sessionUserId: '42', - version: '2025-10', - request, - }) + const result = await runAdminStoreGraphQLOperation({context, request}) expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) expect(graphqlRequest).toHaveBeenCalledWith({ @@ -50,16 +56,14 @@ describe('runAdminStoreGraphQLOperation', () => { }) }) - test('clears stored auth and throws a re-auth error on 401', async () => { + test('clears stored auth and throws a re-auth error on 401 using the real session scopes', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toMatchObject({ + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -68,17 +72,13 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('GraphQL operation failed.') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('GraphQL operation failed.') }) test('rethrows non-GraphQL errors', async () => { vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('boom') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('boom') }) }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/execute/admin-transport.ts similarity index 66% rename from packages/cli/src/cli/services/store/admin-graphql-transport.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.ts index 1a104ee160e..9ba1dced9c1 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.ts @@ -2,11 +2,11 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth-recovery.js' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './session.js' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import type {AdminStoreGraphQLContext} from './admin-context.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false @@ -15,10 +15,7 @@ function isGraphQLClientError(error: unknown): error is {response: {errors?: unk } export async function runAdminStoreGraphQLOperation(input: { - store: string - adminSession: AdminSession - sessionUserId: string - version: string + context: AdminStoreGraphQLContext request: PreparedStoreExecuteRequest }): Promise { try { @@ -28,8 +25,8 @@ export async function runAdminStoreGraphQLOperation(input: { return graphqlRequest({ query: input.request.query, api: 'Admin', - url: adminUrl(input.adminSession.storeFqdn, input.version, input.adminSession), - token: input.adminSession.token, + url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession), + token: input.context.adminSession.token, variables: input.request.parsedVariables, responseOptions: {handleErrors: false}, }) @@ -38,11 +35,11 @@ export async function runAdminStoreGraphQLOperation(input: { }) } catch (error) { if (isGraphQLClientError(error) && error.response.status === 401) { - clearStoredStoreAppSession(input.store, input.sessionUserId) + clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) throw reauthenticateStoreAuthError( - `Stored app authentication for ${input.store} is no longer valid.`, - input.store, - '', + `Stored app authentication for ${input.context.session.store} is no longer valid.`, + input.context.session.store, + input.context.session.scopes.join(','), ) } diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts new file mode 100644 index 00000000000..f29b1aed02a --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -0,0 +1,73 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {executeStoreOperation} from './index.js' +import {prepareStoreExecuteRequest} from './request.js' +import {getStoreGraphQLTarget} from './targets.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./request.js') +vi.mock('./targets.js') +vi.mock('@shopify/cli-kit/node/ui') + +describe('executeStoreOperation', () => { + const request = { + query: 'query { shop { name } }', + parsedOperation: {operationDefinition: {operation: 'query'}}, + parsedVariables: {id: 'gid://shopify/Shop/1'}, + requestedVersion: '2025-10', + } as any + const context = {kind: 'admin-context'} as any + const result = {data: {shop: {name: 'Test shop'}}} + const target = { + prepareContext: vi.fn(), + execute: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prepareStoreExecuteRequest).mockResolvedValue(request) + vi.mocked(getStoreGraphQLTarget).mockReturnValue(target as any) + target.prepareContext.mockResolvedValue(context) + target.execute.mockResolvedValue(result) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('prepares the request, loads context, and returns the execution result', async () => { + await expect( + executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + version: '2025-10', + }), + ).resolves.toEqual(result) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + queryFile: undefined, + variables: '{"id":"gid://shopify/Shop/1"}', + variableFile: undefined, + version: '2025-10', + allowMutations: undefined, + }) + expect(target.prepareContext).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + requestedVersion: '2025-10', + }) + expect(target.execute).toHaveBeenCalledWith({context, request}) + }) + + test('defaults to the admin target', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + }) +}) diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute/index.ts similarity index 67% rename from packages/cli/src/cli/services/store/execute.ts rename to packages/cli/src/cli/services/store/execute/index.ts index 899bf5a662d..1394a37d262 100644 --- a/packages/cli/src/cli/services/store/execute.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,8 +1,7 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' -import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { store: string @@ -11,12 +10,11 @@ interface ExecuteStoreOperationInput { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean } -export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { +export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { const target = getStoreGraphQLTarget(input.api ?? 'admin') const request = await prepareStoreExecuteRequest({ @@ -24,7 +22,6 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): queryFile: input.queryFile, variables: input.variables, variableFile: input.variableFile, - outputFile: input.outputFile, version: input.version, allowMutations: input.allowMutations, }) @@ -35,11 +32,5 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({ - store: input.store, - context, - request, - }) - - await writeOrOutputStoreExecuteResult(result, request.outputFile) + return await target.execute({context, request}) } diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts similarity index 96% rename from packages/cli/src/cli/services/store/execute-request.test.ts rename to packages/cli/src/cli/services/store/execute/request.test.ts index 7146746bced..ce995fa9919 100644 --- a/packages/cli/src/cli/services/store/execute-request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {prepareStoreExecuteRequest} from './execute-request.js' +import {prepareStoreExecuteRequest} from './request.js' vi.mock('@shopify/cli-kit/node/fs') @@ -13,16 +13,15 @@ describe('prepareStoreExecuteRequest', () => { const request = await prepareStoreExecuteRequest({ query: 'query { shop { name } }', variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', version: '2025-07', }) expect(request).toMatchObject({ query: 'query { shop { name } }', parsedVariables: {id: 'gid://shopify/Shop/1'}, - outputFile: '/tmp/result.json', requestedVersion: '2025-07', }) + expect(request).not.toHaveProperty('outputFile') }) test('reads the query from a file', async () => { diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute/request.ts similarity index 98% rename from packages/cli/src/cli/services/store/execute-request.ts rename to packages/cli/src/cli/services/store/execute/request.ts index 7524b74b87d..2e605392682 100644 --- a/packages/cli/src/cli/services/store/execute-request.ts +++ b/packages/cli/src/cli/services/store/execute/request.ts @@ -11,7 +11,6 @@ export interface PreparedStoreExecuteRequest { query: string parsedOperation: ParsedGraphQLOperation parsedVariables?: {[key: string]: unknown} - outputFile?: string requestedVersion?: string } @@ -132,7 +131,6 @@ export async function prepareStoreExecuteRequest(input: { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean }): Promise { @@ -145,7 +143,6 @@ export async function prepareStoreExecuteRequest(input: { query, parsedOperation, parsedVariables, - outputFile: input.outputFile, requestedVersion: input.version, } } diff --git a/packages/cli/src/cli/services/store/execute/result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts new file mode 100644 index 00000000000..083e5329019 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -0,0 +1,93 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {writeFile} from '@shopify/cli-kit/node/fs' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {writeOrOutputStoreExecuteResult} from './result.js' + +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/ui') + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +describe('writeOrOutputStoreExecuteResult', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + + beforeEach(() => { + vi.clearAllMocks() + mockAndCaptureOutput().clear() + }) + + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + + test('writes results to a file when outputFile is provided', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Operation succeeded.', + body: 'Results written to /tmp/results.json', + }) + }) + + test('writes results to stdout when no outputFile is provided', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) + + expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) + expect(output.output()).toContain('Test shop') + }) + + test('suppresses success rendering in json mode', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + + expect(renderSuccess).not.toHaveBeenCalled() + expect(output.output()).toContain('Test shop') + }) + + test('writes json results to stdout without writing to stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + + try { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + } finally { + streams.restore() + } + + expect(streams.stdout()).toContain('"name": "Test shop"') + expect(streams.stderr()).toBe('') + }) + + test('suppresses success rendering when writing a file in json mode', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json', 'json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/cli/services/store/execute/result.ts b/packages/cli/src/cli/services/store/execute/result.ts new file mode 100644 index 00000000000..47e71bd678f --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/result.ts @@ -0,0 +1,38 @@ +import {writeFile} from '@shopify/cli-kit/node/fs' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +type StoreExecuteOutputFormat = 'text' | 'json' + +function serializeStoreExecuteResult(result: unknown): string { + return JSON.stringify(result, null, 2) +} + +function renderStoreExecuteSuccess(outputFile?: string): void { + if (outputFile) { + renderSuccess({ + headline: 'Operation succeeded.', + body: `Results written to ${outputFile}`, + }) + return + } + + renderSuccess({headline: 'Operation succeeded.'}) +} + +export async function writeOrOutputStoreExecuteResult( + result: unknown, + outputFile?: string, + format: StoreExecuteOutputFormat = 'text', +): Promise { + const serializedResult = serializeStoreExecuteResult(result) + + if (outputFile) { + await writeFile(outputFile, serializedResult) + if (format === 'text') renderStoreExecuteSuccess(outputFile) + return + } + + if (format === 'text') renderStoreExecuteSuccess() + outputResult(serializedResult) +} diff --git a/packages/cli/src/cli/services/store/graphql-targets.test.ts b/packages/cli/src/cli/services/store/execute/targets.test.ts similarity index 57% rename from packages/cli/src/cli/services/store/graphql-targets.test.ts rename to packages/cli/src/cli/services/store/execute/targets.test.ts index a5fba30398d..28fc67d8b92 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.test.ts +++ b/packages/cli/src/cli/services/store/execute/targets.test.ts @@ -1,11 +1,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -import {getStoreGraphQLTarget} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {getStoreGraphQLTarget} from './targets.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./admin-graphql-context.js') -vi.mock('./admin-graphql-transport.js') +vi.mock('./admin-context.js') +vi.mock('./admin-transport.js') describe('getStoreGraphQLTarget', () => { beforeEach(() => { @@ -18,28 +19,26 @@ describe('getStoreGraphQLTarget', () => { const context = { adminSession: {token: 'token', storeFqdn: 'shop.myshopify.com'}, version: '2025-10', - sessionUserId: '42', + session: { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, } vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValue(context) vi.mocked(runAdminStoreGraphQLOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) await expect(target.prepareContext({store: 'shop.myshopify.com', requestedVersion: '2025-10'})).resolves.toEqual(context) - - await expect(target.execute({store: 'shop.myshopify.com', context, request})).resolves.toEqual({ - data: {shop: {name: 'Test shop'}}, - }) + await expect(target.execute({context, request})).resolves.toEqual({data: {shop: {name: 'Test shop'}}}) expect(prepareAdminStoreGraphQLContext).toHaveBeenCalledWith({ store: 'shop.myshopify.com', userSpecifiedVersion: '2025-10', }) - expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({context, request}) }) }) diff --git a/packages/cli/src/cli/services/store/graphql-targets.ts b/packages/cli/src/cli/services/store/execute/targets.ts similarity index 61% rename from packages/cli/src/cli/services/store/graphql-targets.ts rename to packages/cli/src/cli/services/store/execute/targets.ts index 809568d7e2e..125a86410b1 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.ts +++ b/packages/cli/src/cli/services/store/execute/targets.ts @@ -1,7 +1,7 @@ import {BugError} from '@shopify/cli-kit/node/error' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext, AdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext, type AdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' export type StoreGraphQLApi = 'admin' @@ -11,13 +11,10 @@ interface PrepareStoreGraphQLTargetContextInput { } interface ExecuteStoreGraphQLTargetInput { - store: string context: TContext request: PreparedStoreExecuteRequest } -// Internal seam for store-scoped GraphQL APIs. Different targets may need different -// auth/context preparation and execution behavior, so each target owns both phases. interface StoreGraphQLTarget { id: StoreGraphQLApi prepareContext(input: PrepareStoreGraphQLTargetContextInput): Promise @@ -29,14 +26,8 @@ const adminStoreGraphQLTarget: StoreGraphQLTarget = { prepareContext: async ({store, requestedVersion}) => { return prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: requestedVersion}) }, - execute: async ({store, context, request}) => { - return runAdminStoreGraphQLOperation({ - store, - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + execute: async ({context, request}) => { + return runAdminStoreGraphQLOperation({context, request}) }, } diff --git a/packages/cli/src/cli/services/store/session.ts b/packages/cli/src/cli/services/store/session.ts deleted file mode 100644 index 53100a27e87..00000000000 --- a/packages/cli/src/cli/services/store/session.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {storeAuthSessionKey} from './auth-config.js' - -export interface StoredStoreAppSession { - store: string - clientId: string - userId: string - accessToken: string - refreshToken?: string - scopes: string[] - acquiredAt: string - expiresAt?: string - refreshTokenExpiresAt?: string - associatedUser?: { - id: number - email?: string - firstName?: string - lastName?: string - accountOwner?: boolean - } -} - -interface StoredStoreAppSessionBucket { - currentUserId: string - sessionsByUserId: {[userId: string]: StoredStoreAppSession} -} - -interface StoreSessionSchema { - [key: string]: StoredStoreAppSessionBucket -} - -let _storeSessionStorage: LocalStorage | undefined - -// Per-store, per-user session storage for PKCE online tokens. -function storeSessionStorage() { - _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) - return _storeSessionStorage -} - -export function getStoredStoreAppSession( - store: string, - storage: LocalStorage = storeSessionStorage(), -): StoredStoreAppSession | undefined { - const key = storeAuthSessionKey(store) - const storedBucket = storage.get(key) - if (!storedBucket || typeof storedBucket !== 'object') return undefined - - const {sessionsByUserId, currentUserId} = storedBucket as Partial - - if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || typeof currentUserId !== 'string') { - storage.delete(key) - return undefined - } - - const session = sessionsByUserId[currentUserId] - if (!session) { - storage.delete(key) - return undefined - } - - return session -} - -export function setStoredStoreAppSession( - session: StoredStoreAppSession, - storage: LocalStorage = storeSessionStorage(), -): void { - const key = storeAuthSessionKey(session.store) - const existingBucket = storage.get(key) - - const nextBucket: StoredStoreAppSessionBucket = { - currentUserId: session.userId, - sessionsByUserId: { - ...(existingBucket?.sessionsByUserId ?? {}), - [session.userId]: session, - }, - } - - storage.set(key, nextBucket) -} - -export function clearStoredStoreAppSession( - store: string, - userIdOrStorage?: string | LocalStorage, - maybeStorage?: LocalStorage, -): void { - const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined - const storage = - (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() - - const key = storeAuthSessionKey(store) - - if (!userId) { - storage.delete(key) - return - } - - const existingBucket = storage.get(key) - if (!existingBucket) return - - const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId - - const remainingUserIds = Object.keys(remainingSessions) - if (remainingUserIds.length === 0) { - storage.delete(key) - return - } - - storage.set(key, { - currentUserId: - existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, - sessionsByUserId: remainingSessions, - }) -} - -const EXPIRY_MARGIN_MS = 4 * 60 * 1000 - -export function isSessionExpired(session: StoredStoreAppSession): boolean { - if (!session.expiresAt) return false - - const expiresAtMs = new Date(session.expiresAt).getTime() - if (Number.isNaN(expiresAtMs)) return true - - return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() -} diff --git a/packages/e2e/helpers/browser-login.ts b/packages/e2e/helpers/browser-login.ts index ba637b6d382..da5a4766c50 100644 --- a/packages/e2e/helpers/browser-login.ts +++ b/packages/e2e/helpers/browser-login.ts @@ -1,5 +1,24 @@ import type {Page} from '@playwright/test' +/** + * Sets an input field's value via the DOM, bypassing Playwright's fill() API. + * + * Security (shopify/bugbounty#3638393): Playwright's test runner logs every + * fill() call — including the literal value — into trace files, which are + * uploaded as publicly downloadable CI artifacts. Using evaluate() to set + * the value directly avoids the Playwright action log entirely. The runner's + * tracing instruments at a level above context.tracing, so context.tracing.stop() + * does NOT prevent the leak. + */ +async function fillSensitive(page: Page, selector: string, value: string): Promise { + const locator = page.locator(selector).first() + await locator.evaluate((el, val) => { + ;(el as unknown as {value: string}).value = val + el.dispatchEvent(new Event('input', {bubbles: true})) + el.dispatchEvent(new Event('change', {bubbles: true})) + }, value) +} + /** * Completes the Shopify OAuth login flow on a Playwright page. */ @@ -9,12 +28,12 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, try { // Fill in email await page.waitForSelector('input[name="account[email]"], input[type="email"]', {timeout: 60_000}) - await page.locator('input[name="account[email]"], input[type="email"]').first().fill(email) + await fillSensitive(page, 'input[name="account[email]"], input[type="email"]', email) await page.locator('button[type="submit"]').first().click() // Fill in password await page.waitForSelector('input[name="account[password]"], input[type="password"]', {timeout: 60_000}) - await page.locator('input[name="account[password]"], input[type="password"]').first().fill(password) + await fillSensitive(page, 'input[name="account[password]"], input[type="password"]', password) await page.locator('button[type="submit"]').first().click() // Handle any confirmation/approval page @@ -27,12 +46,10 @@ export async function completeLogin(page: Page, loginUrl: string, email: string, // No confirmation page — expected } } catch (error) { - const pageContent = await page.content().catch(() => '(failed to get content)') const pageUrl = page.url() - throw new Error( - `Login failed at ${pageUrl}\n` + - `Original error: ${error}\n` + - `Page HTML (first 2000 chars): ${pageContent.slice(0, 2000)}`, - ) + // Clear the page so failure artifacts (screenshots, trace snapshots) do + // not capture the login form with credentials still populated. + await page.goto('about:blank').catch(() => {}) + throw new Error(`Login failed at ${pageUrl}\nOriginal error: ${error}`) } } diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 8b9d5de63e8..fa0b9c0d312 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -33,8 +33,7 @@ "@types/node": "18.19.70", "execa": "^7.2.0", "node-pty": "^1.0.0", - "strip-ansi": "^7.1.0", - "tempy": "^1.0.1" + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=20.10.0" diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index 8a67005dffe..b01187d5f85 100644 --- a/packages/eslint-plugin-cli/config.js +++ b/packages/eslint-plugin-cli/config.js @@ -2,7 +2,6 @@ const shopifyPlugin = require('@shopify/eslint-plugin') const vitestPlugin = require('@vitest/eslint-plugin') const unusedImportsPlugin = require('eslint-plugin-unused-imports') const tsdocPlugin = require('eslint-plugin-tsdoc') -const jsdocPlugin = require('eslint-plugin-jsdoc') const noCatchAllPlugin = require('eslint-plugin-no-catch-all') const eslintConfigPrettier = require('eslint-config-prettier') const globals = require('globals') @@ -176,7 +175,6 @@ const baseRules = { }, ], 'tsdoc/syntax': 'error', - 'jsdoc/require-returns-description': 'error', 'promise/catch-or-return': ['error', {allowFinally: true}], 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ @@ -280,7 +278,6 @@ const config = [ vitest: vitestPlugin, 'unused-imports': unusedImportsPlugin, tsdoc: tsdocPlugin, - jsdoc: jsdocPlugin, 'no-catch-all': noCatchAllPlugin, '@shopify/cli': cliPlugin, }, diff --git a/packages/eslint-plugin-cli/package.json b/packages/eslint-plugin-cli/package.json index 366a043d01b..30988fc9234 100644 --- a/packages/eslint-plugin-cli/package.json +++ b/packages/eslint-plugin-cli/package.json @@ -16,13 +16,10 @@ "license": "MIT", "author": "Shopify Inc.", "dependencies": { - "@babel/core": "7.27.4", "@shopify/eslint-plugin": "50.0.0", "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "eslint-config-prettier": "10.1.5", - "eslint-plugin-jsdoc": "50.7.1", - "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-no-catch-all": "1.1.0", "eslint-plugin-prettier": "5.5.1", "eslint-plugin-react": "7.37.5", @@ -31,8 +28,7 @@ "eslint-plugin-unused-imports": "4.1.4", "@vitest/eslint-plugin": "1.1.44", "globals": "16.2.0", - "execa": "7.2.0", - "debug": "4.4.0" + "execa": "7.2.0" }, "devDependencies": { "prettier": "3.8.1" diff --git a/packages/eslint-plugin-cli/rules/no-inline-graphql.js b/packages/eslint-plugin-cli/rules/no-inline-graphql.js index 7acf5006eaa..8959ccda80b 100644 --- a/packages/eslint-plugin-cli/rules/no-inline-graphql.js +++ b/packages/eslint-plugin-cli/rules/no-inline-graphql.js @@ -2,7 +2,7 @@ const path = require('path') const fs = require('fs') const crypto = require('crypto') -const debug = require('debug')('eslint-plugin-cli:no-inline-graphql') +const debugEnabled = process.env.DEBUG && process.env.DEBUG.includes('eslint-plugin-cli') /** * Check if using a gql`` template literal @@ -38,7 +38,7 @@ function checkKnownFailuresIfShouldFail(context) { const shouldFail = !knownFailures[relativePath] || knownFailures[relativePath] !== fileHash if (shouldFail) { - debug(`Reporting inline GraphQL tag fail for - '${relativePath}': '${fileHash}',`) + if (debugEnabled) console.error(`eslint-plugin-cli:no-inline-graphql Reporting inline GraphQL tag fail for - '${relativePath}': '${fileHash}',`) } return shouldFail diff --git a/packages/ui-extensions-dev-console/package.json b/packages/ui-extensions-dev-console/package.json index 46ed8f0b4f2..6c67cd419ba 100644 --- a/packages/ui-extensions-dev-console/package.json +++ b/packages/ui-extensions-dev-console/package.json @@ -18,7 +18,6 @@ "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.14.2", "react-toastify": "^9.1.3", "react-transition-group": "^4.4.5" }, diff --git a/packages/ui-extensions-dev-console/src/App.tsx b/packages/ui-extensions-dev-console/src/App.tsx index d94d2667338..a2b346a1c16 100644 --- a/packages/ui-extensions-dev-console/src/App.tsx +++ b/packages/ui-extensions-dev-console/src/App.tsx @@ -1,5 +1,5 @@ import {Layout} from '@/foundation/Layout' -import {Routes} from '@/foundation/Routes' +import {Extensions} from '@/sections/Extensions' import {Toast} from '@/foundation/Toast' import {Theme} from '@/foundation/Theme' import {ModalContainer} from '@/foundation/ModalContainer' @@ -29,7 +29,7 @@ function App() { - + diff --git a/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx b/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx deleted file mode 100644 index 6dd59db7199..00000000000 --- a/packages/ui-extensions-dev-console/src/foundation/Routes/Routes.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {Extensions} from '@/sections/Extensions' -import React from 'react' -import {BrowserRouter, Routes as ReactRouterRoutes, Route} from 'react-router-dom' - -export function Routes() { - return ( - - - } /> - - - ) -} diff --git a/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts b/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts deleted file mode 100644 index 254d69e7060..00000000000 --- a/packages/ui-extensions-dev-console/src/foundation/Routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Routes' diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index e2ef8e8eb61..43e86fcb2ad 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -182,4 +182,10 @@ export interface App { } supportEmail?: string supportLocales?: string[] + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 779bdad0570..6d6ac541a03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: eslint: specifier: ^9.26.0 version: 9.39.3(jiti@2.6.1) + eslint-plugin-jsdoc: + specifier: 50.7.1 + version: 50.7.1(eslint@9.39.3(jiti@2.6.1)) execa: specifier: ^7.2.0 version: 7.2.0 @@ -330,9 +333,6 @@ importers: '@opentelemetry/sdk-metrics': specifier: 1.30.0 version: 1.30.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': - specifier: 1.28.0 - version: 1.28.0 '@shopify/toml-patch': specifier: 0.3.0 version: 0.3.0 @@ -360,9 +360,6 @@ importers: color-json: specifier: 3.0.5 version: 3.0.5 - commondir: - specifier: 1.0.1 - version: 1.0.1 conf: specifier: 11.0.2 version: 11.0.2 @@ -393,9 +390,6 @@ importers: fs-extra: specifier: 11.1.0 version: 11.1.0 - get-port-please: - specifier: 3.1.2 - version: 3.1.2 gradient-string: specifier: 2.0.2 version: 2.0.2 @@ -471,12 +465,6 @@ importers: supports-hyperlinks: specifier: 3.1.0 version: 3.1.0 - tempy: - specifier: 3.1.0 - version: 3.1.0 - terminal-link: - specifier: 3.0.0 - version: 3.0.0 ts-error: specifier: 1.0.6 version: 1.0.6 @@ -487,9 +475,6 @@ importers: specifier: 3.24.4 version: 3.24.4 devDependencies: - '@types/commondir': - specifier: ^1.0.0 - version: 1.0.2 '@types/diff': specifier: ^5.2.3 version: 5.2.3 @@ -563,15 +548,9 @@ importers: strip-ansi: specifier: ^7.1.0 version: 7.1.0 - tempy: - specifier: ^1.0.1 - version: 1.0.1 packages/eslint-plugin-cli: dependencies: - '@babel/core': - specifier: 7.27.4 - version: 7.27.4 '@shopify/eslint-plugin': specifier: 50.0.0 version: 50.0.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) @@ -584,21 +563,12 @@ importers: '@vitest/eslint-plugin': specifier: 1.1.44 version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) - debug: - specifier: 4.4.0 - version: 4.4.0(supports-color@8.1.1) eslint: specifier: ^9.0.0 version: 9.39.3(jiti@2.6.1) eslint-config-prettier: specifier: 10.1.5 version: 10.1.5(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsdoc: - specifier: 50.7.1 - version: 50.7.1(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsx-a11y: - specifier: 6.10.2 - version: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: specifier: 1.1.0 version: 1.1.0(eslint@9.39.3(jiti@2.6.1)) @@ -711,9 +681,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-router-dom: - specifier: ^6.14.2 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-toastify: specifier: ^9.1.3 version: 9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -802,9 +769,6 @@ importers: simple-git: specifier: 3.32.3 version: 3.32.3 - tempy: - specifier: 3.0.0 - version: 3.0.0 packages: @@ -3364,10 +3328,6 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - '@repeaterjs/repeater@3.0.6': resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} @@ -3915,9 +3875,6 @@ packages: '@types/cli-progress@3.11.6': resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} - '@types/commondir@1.0.2': - resolution: {integrity: sha512-ugIRUhO6vLS6Pi5Pz/0yuMYX7Q1rsEpXNrU7ef6rVdH+cb3hTDz8HL55C0QINDqMB4ZWsrE8lLWyYUbZKPhduQ==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4285,10 +4242,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -4314,10 +4267,6 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} - ansi-escapes@5.0.0: - resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} - engines: {node: '>=12'} - ansi-escapes@6.2.1: resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} engines: {node: '>=14.16'} @@ -4734,10 +4683,6 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} @@ -4871,9 +4816,6 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - commondir@1.0.1: - resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -4983,10 +4925,6 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} - engines: {node: '>=8'} - crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} @@ -5128,10 +5066,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - del@6.1.1: - resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} - engines: {node: '>=10'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5859,9 +5793,6 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} - get-port-please@3.1.2: - resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} - get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -6369,14 +6300,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-cwd@2.2.0: - resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} - engines: {node: '>=6'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -7339,10 +7262,6 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} - p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -7672,19 +7591,6 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-toastify@9.1.3: resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==} peerDependencies: @@ -7869,11 +7775,6 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -8312,30 +8213,14 @@ packages: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} - temp-dir@3.0.0: - resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} - engines: {node: '>=14.16'} - - tempy@1.0.1: - resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} - engines: {node: '>=10'} - tempy@3.0.0: resolution: {integrity: sha512-B2I9X7+o2wOaW4r/CWMkpOO9mdiTRCxXNgob6iGvPmfPWgH/KyUD6Uy5crtWBxIBe3YrNZKR2lSzv1JJKWD4vA==} engines: {node: '>=14.16'} - tempy@3.1.0: - resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} - engines: {node: '>=14.16'} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terminal-link@3.0.0: - resolution: {integrity: sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==} - engines: {node: '>=12'} - terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -8503,10 +8388,6 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@0.16.0: - resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} - engines: {node: '>=10'} - type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -8632,10 +8513,6 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} - unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} - engines: {node: '>=8'} - unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} @@ -12505,8 +12382,6 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@remix-run/router@1.23.2': {} - '@repeaterjs/repeater@3.0.6': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -12640,16 +12515,12 @@ snapshots: '@shopify/eslint-plugin-cli@file:packages/eslint-plugin-cli(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@3.2.4(@types/node@18.19.70)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@18.19.70)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3))': dependencies: - '@babel/core': 7.27.4 '@shopify/eslint-plugin': 50.0.0(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@vitest/eslint-plugin': 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)(vitest@3.2.4(@types/node@18.19.70)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@18.19.70)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) - debug: 4.4.0(supports-color@8.1.1) eslint: 9.39.3(jiti@2.6.1) eslint-config-prettier: 10.1.5(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsdoc: 50.7.1(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-no-catch-all: 1.1.0(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-prettier: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) @@ -13291,8 +13162,6 @@ snapshots: dependencies: '@types/node': 18.19.70 - '@types/commondir@1.0.2': {} - '@types/deep-eql@4.0.2': {} '@types/diff@5.2.3': {} @@ -13698,11 +13567,6 @@ snapshots: agent-base@7.1.4: {} - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -13734,10 +13598,6 @@ snapshots: dependencies: type-fest: 0.21.3 - ansi-escapes@5.0.0: - dependencies: - type-fest: 1.4.0 - ansi-escapes@6.2.1: {} ansi-escapes@7.3.0: @@ -14237,8 +14097,6 @@ snapshots: ci-info@3.9.0: {} - clean-stack@2.2.0: {} - clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 @@ -14356,8 +14214,6 @@ snapshots: common-tags@1.8.2: {} - commondir@1.0.1: {} - compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13 @@ -14488,8 +14344,6 @@ snapshots: dependencies: uncrypto: 0.1.3 - crypto-random-string@2.0.0: {} - crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 @@ -14619,17 +14473,6 @@ snapshots: defu@6.1.4: {} - del@6.1.1: - dependencies: - globby: 11.1.0 - graceful-fs: 4.2.11 - is-glob: 4.0.3 - is-path-cwd: 2.2.0 - is-path-inside: 3.0.3 - p-map: 4.0.0 - rimraf: 3.0.2 - slash: 3.0.0 - delayed-stream@1.0.0: {} dependency-graph@0.11.0: {} @@ -15506,8 +15349,6 @@ snapshots: get-package-type@0.1.0: {} - get-port-please@3.1.2: {} - get-port@7.1.0: {} get-proto@1.0.1: @@ -16102,10 +15943,6 @@ snapshots: is-number@7.0.0: {} - is-path-cwd@2.2.0: {} - - is-path-inside@3.0.3: {} - is-plain-obj@1.1.0: {} is-plain-obj@4.1.0: {} @@ -17156,10 +16993,6 @@ snapshots: p-map@2.1.0: {} - p-map@4.0.0: - dependencies: - aggregate-error: 3.1.0 - p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -17490,18 +17323,6 @@ snapshots: react-refresh@0.18.0: {} - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - - react-router@6.30.3(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-toastify@9.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 1.2.1 @@ -17716,10 +17537,6 @@ snapshots: rfdc@1.4.1: {} - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -18245,16 +18062,6 @@ snapshots: temp-dir@2.0.0: {} - temp-dir@3.0.0: {} - - tempy@1.0.1: - dependencies: - del: 6.1.1 - is-stream: 2.0.1 - temp-dir: 2.0.0 - type-fest: 0.16.0 - unique-string: 2.0.0 - tempy@3.0.0: dependencies: is-stream: 3.0.0 @@ -18262,20 +18069,8 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - tempy@3.1.0: - dependencies: - is-stream: 3.0.0 - temp-dir: 3.0.0 - type-fest: 2.19.0 - unique-string: 3.0.0 - term-size@2.2.1: {} - terminal-link@3.0.0: - dependencies: - ansi-escapes: 5.0.0 - supports-hyperlinks: 3.1.0 - terminal-size@4.0.1: {} test-exclude@7.0.2: @@ -18431,8 +18226,6 @@ snapshots: type-fest@0.13.1: {} - type-fest@0.16.0: {} - type-fest@0.21.3: {} type-fest@0.6.0: {} @@ -18549,10 +18342,6 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - unique-string@2.0.0: - dependencies: - crypto-random-string: 2.0.0 - unique-string@3.0.0: dependencies: crypto-random-string: 4.0.0 diff --git a/workspace/package.json b/workspace/package.json index ad64174f312..f37f031c5f6 100644 --- a/workspace/package.json +++ b/workspace/package.json @@ -17,7 +17,6 @@ "devDependencies": { "@actions/core": "^1.10.0", "git-diff": "^2.0.6", - "simple-git": "3.32.3", - "tempy": "3.0.0" + "simple-git": "3.32.3" } } diff --git a/workspace/src/type-diff.js b/workspace/src/type-diff.js index c9c057f7d28..e33c879f572 100644 --- a/workspace/src/type-diff.js +++ b/workspace/src/type-diff.js @@ -4,7 +4,8 @@ import * as path from 'pathe' import * as url from 'url' import {execa} from 'execa' import fg from 'fast-glob' -import {temporaryDirectoryTask} from 'tempy' +import {mkdtemp, rm} from 'fs/promises' +import os from 'os' import git from 'simple-git' import {setOutput} from '@actions/core' import {promises as fs, existsSync} from 'fs' @@ -95,7 +96,8 @@ ${ ) } -await temporaryDirectoryTask(async (tmpDir) => { +const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'type-diff-')) +try { const baselineDirectory = await cloneCLIRepository(tmpDir) const currentDirectory = path.join(url.fileURLToPath(new URL('.', import.meta.url)), '../..') const baselineFiles = await build(baselineDirectory, {name: 'baseline'}) @@ -106,4 +108,6 @@ await temporaryDirectoryTask(async (tmpDir) => { baselineFiles, currentFiles, }) -}) +} finally { + await rm(tmpDir, {recursive: true, force: true, maxRetries: 2}) +}