From 5e4d1a2c4754d1849ba547fe44529acc93dbb480 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Wed, 14 Jan 2026 10:44:23 -0800 Subject: [PATCH 01/60] Update CODEOWNERS --- .github/CODEOWNERS | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 66c2bf0b439..5e044f94be2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,13 +2,13 @@ * @shopify/app-inner-loop # Theme team and CLI owners should review theme changes -packages/cli-kit/src/private/themes/* @shopify/developer-tools @shopify/app-inner-loop -packages/cli-kit/src/public/**/themes/* @shopify/developer-tools @shopify/app-inner-loop -packages/theme/** @shopify/developer-tools @shopify/app-inner-loop +packages/cli-kit/src/private/themes/* @shopify/developer-platforms @shopify/app-inner-loop +packages/cli-kit/src/public/**/themes/* @shopify/developer-platforms @shopify/app-inner-loop +packages/theme/** @shopify/developer-platforms @shopify/app-inner-loop # These are metafiles that can be reviewed by anyone -.changeset/* @shopify/developer-tools @shopify/app-inner-loop -.github/CODEOWNERS @shopify/developer-tools @shopify/app-inner-loop -docs-shopify.dev/** @shopify/developer-tools @shopify/app-inner-loop -packages/cli/oclif.manifest.json @shopify/developer-tools @shopify/app-inner-loop -packages/cli/README.md @shopify/developer-tools @shopify/app-inner-loop +.changeset/* @shopify/developer-platforms @shopify/app-inner-loop +.github/CODEOWNERS @shopify/developer-platforms @shopify/app-inner-loop +docs-shopify.dev/** @shopify/developer-platforms @shopify/app-inner-loop +packages/cli/oclif.manifest.json @shopify/developer-platforms @shopify/app-inner-loop +packages/cli/README.md @shopify/developer-platforms @shopify/app-inner-loop From 274269aef4fb12939cffc994512d4194136e19d1 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 14 Jan 2026 15:18:26 -0500 Subject: [PATCH 02/60] Update feature flag gating for extension templates --- .../generated/organization_exp_flags.ts | 74 +++++++ .../queries/organization_exp_flags.graphql | 6 + .../app-management-client.test.ts | 184 ++++++++++++++++-- .../app-management-client.ts | 55 +++++- 4 files changed, 303 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts create mode 100644 packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts new file mode 100644 index 00000000000..c953c83d465 --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/organization_exp_flags.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type OrganizationExpFlagsQueryVariables = Types.Exact<{ + organizationId: Types.Scalars['OrganizationID']['input'] + flagHandles: Types.Scalars['String']['input'][] | Types.Scalars['String']['input'] +}> + +export type OrganizationExpFlagsQuery = {organization?: {id: string; enabledFlags: boolean[]} | null} + +export const OrganizationExpFlags = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'OrganizationExpFlags'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'organizationId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'OrganizationID'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'flagHandles'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'organizationId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'organizationId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'enabledFlags'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'flagHandles'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'flagHandles'}}, + }, + ], + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql new file mode 100644 index 00000000000..add09011bb2 --- /dev/null +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/queries/organization_exp_flags.graphql @@ -0,0 +1,6 @@ +query OrganizationExpFlags($organizationId: OrganizationID!, $flagHandles: [String!]!) { + organization(organizationId: $organizationId) { + id + enabledFlags(flagHandles: $flagHandles) + } +} diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 65db3b6b627..aa70c7eece5 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -8,6 +8,7 @@ import { versionDeepLink, } from './app-management-client.js' import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' +import {OrganizationExpFlagsQuery} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import { testUIExtension, testRemoteExtensionTemplates, @@ -187,7 +188,6 @@ describe('templateSpecifications', () => { organization: { id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), flag_allowedFlag: true, - flag_notAllowedFlag: false, }, } vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse) @@ -200,17 +200,12 @@ describe('templateSpecifications', () => { // Then expect(vi.mocked(businessPlatformOrganizationsRequest)).toHaveBeenCalledWith({ - query: ` - query OrganizationBetaFlags($organizationId: OrganizationID!) { - organization(organizationId: $organizationId) { - id - flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag") - flag_notAllowedFlag: hasFeatureFlag(handle: "notAllowedFlag") - } - }`, + query: expect.stringContaining('flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag")'), token: 'business-platform-token', organizationId: orgApp.organizationId, - variables: {organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId)}, + variables: { + organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + }, unauthorizedHandler: { type: 'token_refresh', handler: expect.any(Function), @@ -249,6 +244,64 @@ describe('templateSpecifications', () => { expect(groupOrder).toEqual(['GroupA', 'GroupB', 'GroupC']) }) + test('fetches and filters templates by exp flags using enabledFlags', async () => { + // Given + const orgApp = testOrganizationApp() + const templateWithExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationExpFlags: ['hash_allowed'], + } + const templateWithDisallowedExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationExpFlags: ['hash_not_allowed'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithExpFlag, + templateWithDisallowedExpFlag, + ] + const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates)) + vi.mocked(fetch).mockImplementation(mockedFetch) + + const mockedBetaFlagsResponse: OrganizationBetaFlagsQuerySchema = { + organization: { + id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + }, + } + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedBetaFlagsResponse) + + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: { + id: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + enabledFlags: [true, false], + }, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const client = AppManagementClient.getInstance() + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + const {templates: got} = await client.templateSpecifications(orgApp) + const gotLabels = got.map((template) => template.name) + + // Then + expect(vi.mocked(businessPlatformOrganizationsRequestDoc)).toHaveBeenCalledWith({ + query: expect.objectContaining({kind: 'Document'}), + token: 'business-platform-token', + organizationId: orgApp.organizationId, + variables: { + organizationId: encodedGidFromOrganizationIdForBP(orgApp.organizationId), + flagHandles: ['hash_allowed', 'hash_not_allowed'], + }, + unauthorizedHandler: { + type: 'token_refresh', + handler: expect.any(Function), + }, + }) + const expectedAllowedTemplates = [templateWithoutRules, templateWithExpFlag] + expect(gotLabels).toEqual(expectedAllowedTemplates.map((template) => template.name)) + }) + test('fails with an error message when fetching the specifications list fails', async () => { // Given vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch')) @@ -274,7 +327,11 @@ describe('allowedTemplates', () => { ] // When - const got = await allowedTemplates(templates, () => Promise.resolve({allowedFlag: true, notAllowedFlag: false})) + const got = await allowedTemplates( + templates, + () => Promise.resolve({allowedFlag: true, notAllowedFlag: false}), + () => Promise.resolve({}), + ) // Then expect(got.length).toEqual(2) @@ -293,6 +350,7 @@ describe('allowedTemplates', () => { const got = await allowedTemplates( templates, () => Promise.resolve({allowedFlag: true, notAllowedFlag: false}), + () => Promise.resolve({}), '0.0.0-nightly', ) @@ -300,6 +358,110 @@ describe('allowedTemplates', () => { expect(got.length).toEqual(2) expect(got).toEqual([allowedTemplate, templateDisallowedByMinimumCliVersion]) }) + + test('filters templates by exp flags', async () => { + // Given + const templateWithExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationExpFlags: ['hash_allowed'], + } + const templateWithDisallowedExpFlag: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationExpFlags: ['hash_not_allowed'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithExpFlag, + templateWithDisallowedExpFlag, + ] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({}), + () => Promise.resolve({hash_allowed: true, hash_not_allowed: false}), + ) + + // Then + expect(got.length).toEqual(2) + expect(got).toEqual([templateWithoutRules, templateWithExpFlag]) + }) + + test('filters templates requiring both beta and exp flags', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templateWithOnlyBeta: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[2]!, + organizationBetaFlags: ['betaFlag'], + } + const templateWithOnlyExp: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[3]!, + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [ + templateWithoutRules, + templateWithBothFlags, + templateWithOnlyBeta, + templateWithOnlyExp, + ] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: true}), + () => Promise.resolve({hash_exp: true}), + ) + + // Then + expect(got.length).toEqual(4) + expect(got).toEqual([templateWithoutRules, templateWithBothFlags, templateWithOnlyBeta, templateWithOnlyExp]) + }) + + test('excludes template when beta flag is satisfied but exp flag is not', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [templateWithoutRules, templateWithBothFlags] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: true}), + () => Promise.resolve({hash_exp: false}), + ) + + // Then + expect(got.length).toEqual(1) + expect(got).toEqual([templateWithoutRules]) + }) + + test('excludes template when exp flag is satisfied but beta flag is not', async () => { + // Given + const templateWithBothFlags: GatedExtensionTemplate = { + ...testRemoteExtensionTemplates[1]!, + organizationBetaFlags: ['betaFlag'], + organizationExpFlags: ['hash_exp'], + } + const templates: GatedExtensionTemplate[] = [templateWithoutRules, templateWithBothFlags] + + // When + const got = await allowedTemplates( + templates, + () => Promise.resolve({betaFlag: false}), + () => Promise.resolve({hash_exp: true}), + ) + + // Then + expect(got.length).toEqual(1) + expect(got).toEqual([templateWithoutRules]) + }) }) describe('versionDeepLink', () => { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 1c044c65a68..239532606ed 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -4,6 +4,10 @@ import { OrganizationBetaFlagsQueryVariables, organizationBetaFlagsQuery, } from './app-management-client/graphql/organization_beta_flags.js' +import { + OrganizationExpFlagsQueryVariables, + OrganizationExpFlags, +} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import {environmentVariableNames} from '../../constants.js' import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import { @@ -178,6 +182,7 @@ type ShopEdge = NonNullable type ShopNode = Exclude export interface GatedExtensionTemplate extends ExtensionTemplate { organizationBetaFlags?: string[] + organizationExpFlags?: string[] minimumCliVersion?: string deprecatedFromCliVersion?: string } @@ -491,8 +496,10 @@ export class AppManagementClient implements DeveloperPlatformClient { // uses sortPriority, is gone. let counter = 0 const filteredTemplates = ( - await allowedTemplates(templates, async (betaFlags: string[]) => - this.organizationBetaFlags(organizationId, betaFlags), + await allowedTemplates( + templates, + async (betaFlags: string[]) => this.organizationBetaFlags(organizationId, betaFlags), + async (expFlags: string[]) => this.organizationExpFlags(organizationId, expFlags), ) ).map((template) => ({...template, sortPriority: counter++})) @@ -1082,6 +1089,29 @@ export class AppManagementClient implements DeveloperPlatformClient { return result } + private async organizationExpFlags( + organizationId: string, + allExpFlags: string[], + ): Promise<{[flag: (typeof allExpFlags)[number]]: boolean}> { + const variables: OrganizationExpFlagsQueryVariables = { + organizationId: encodedGidFromOrganizationIdForBP(organizationId), + flagHandles: allExpFlags, + } + const flagsResult = await businessPlatformOrganizationsRequestDoc({ + query: OrganizationExpFlags, + token: await this.businessPlatformToken(), + organizationId, + variables, + unauthorizedHandler: this.createUnauthorizedHandler(), + }) + const result: {[flag: (typeof allExpFlags)[number]]: boolean} = {} + const enabledFlags = flagsResult.organization?.enabledFlags ?? [] + allExpFlags.forEach((flag, index) => { + result[flag] = Boolean(enabledFlags[index]) + }) + return result + } + private async appManagementRequest( options: Omit, 'unauthorizedHandler' | 'token'>, ): Promise { @@ -1282,19 +1312,34 @@ export function diffAppModules({currentModules, selectedVersionModules}: DiffApp export async function allowedTemplates( templates: GatedExtensionTemplate[], betaFlagsFetcher: (betaFlags: string[]) => Promise<{[key: string]: boolean}>, + expFlagsFetcher: (expFlags: string[]) => Promise<{[key: string]: boolean}>, version: string = CLI_KIT_VERSION, ): Promise { + // Extract both types of flags from templates const allBetaFlags = Array.from(new Set(templates.map((ext) => ext.organizationBetaFlags ?? []).flat())) - const enabledBetaFlags = await betaFlagsFetcher(allBetaFlags) + const allExpFlags = Array.from(new Set(templates.map((ext) => ext.organizationExpFlags ?? []).flat())) + + // Fetch both flag types in parallel + const [enabledBetaFlags, enabledExpFlags] = await Promise.all([ + allBetaFlags.length > 0 ? betaFlagsFetcher(allBetaFlags) : Promise.resolve({} as {[key: string]: boolean}), + allExpFlags.length > 0 ? expFlagsFetcher(allExpFlags) : Promise.resolve({} as {[key: string]: boolean}), + ]) + return templates.filter((ext) => { - const hasAnyNeededBetas = + // Check beta flags + const hasNeededBetaFlags = !ext.organizationBetaFlags || ext.organizationBetaFlags.every((flag) => enabledBetaFlags[flag]) + // Check exp flags + const hasNeededExpFlags = + !ext.organizationExpFlags || ext.organizationExpFlags.every((flag) => enabledExpFlags[flag]) + // Version checks const satisfiesMinCliVersion = !ext.minimumCliVersion || versionSatisfies(version, `>=${ext.minimumCliVersion}`) const satisfiesDeprecatedFromCliVersion = !ext.deprecatedFromCliVersion || versionSatisfies(version, `<${ext.deprecatedFromCliVersion}`) const satisfiesVersion = satisfiesMinCliVersion && satisfiesDeprecatedFromCliVersion const satisfiesPreReleaseVersion = isPreReleaseVersion(version) && ext.deprecatedFromCliVersion === undefined - return hasAnyNeededBetas && (satisfiesVersion || satisfiesPreReleaseVersion) + // Must satisfy both flag types AND version requirements + return hasNeededBetaFlags && hasNeededExpFlags && (satisfiesVersion || satisfiesPreReleaseVersion) }) } From 2b1c6305c8bdf077d9d9abf6e0c65543d4bcfab1 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 15 Jan 2026 12:33:27 -0500 Subject: [PATCH 03/60] Update lockfile --- pnpm-lock.yaml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b183faf400d..9fc1b206975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14384,15 +14384,6 @@ snapshots: msw: 2.8.7(@types/node@24.7.0)(typescript@5.8.3) vite: 6.4.1(@types/node@18.19.70)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1) - '@vitest/mocker@3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.1 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - msw: 2.8.7(@types/node@24.7.0)(typescript@5.8.3) - vite: 6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1) - '@vitest/pretty-format@3.2.1': dependencies: tinyrainbow: 2.0.0 @@ -19992,7 +19983,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.1 - '@vitest/mocker': 3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.1(msw@2.8.7(@types/node@24.7.0)(typescript@5.8.3))(vite@6.4.1(@types/node@18.19.70)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.1 '@vitest/runner': 3.2.1 '@vitest/snapshot': 3.2.1 From 615e09bd290c679d0788e5d6aecbb841e3195db2 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 19 Nov 2025 16:14:49 +0100 Subject: [PATCH 04/60] Send websocketUrl on Dev session create/update --- .../app-dev/generated/dev-session-create.ts | 13 ++++++++++- .../app-dev/generated/dev-session-delete.ts | 2 +- .../app-dev/generated/dev-session-update.ts | 13 ++++++++++- .../queries/dev-session-create.graphql | 4 ++-- .../queries/dev-session-update.graphql | 4 ++-- .../generated/types.d.ts | 4 ---- .../app/src/cli/services/dev/extension.ts | 2 +- .../dev/extension/server/middlewares.ts | 10 ++------ .../dev-session/dev-session-process.test.ts | 8 +++++-- .../dev/processes/dev-session/dev-session.ts | 23 +++++++++++++------ .../utilities/developer-platform-client.ts | 3 ++- .../app-management-client.ts | 23 +++++++++++++++---- 12 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts index e1d612e54bc..bffec8887ed 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts @@ -7,6 +7,7 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co export type DevSessionCreateMutationVariables = Types.Exact<{ appId: Types.Scalars['String']['input'] assetsUrl: Types.Scalars['String']['input'] + websocketUrl?: Types.InputMaybe }> export type DevSessionCreateMutation = { @@ -15,7 +16,7 @@ export type DevSessionCreateMutation = { } | null } -export const DevSessionCreate = { +export const DevSessionCreateDocument = { kind: 'Document', definitions: [ { @@ -33,6 +34,11 @@ export const DevSessionCreate = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -51,6 +57,11 @@ export const DevSessionCreate = { name: {kind: 'Name', value: 'assetsUrl'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'websocketUrl'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, + }, ], selectionSet: { kind: 'SelectionSet', diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts index 3f6ccb87cae..f4ee5554fb8 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts @@ -9,7 +9,7 @@ export type DevSessionDeleteMutationVariables = Types.Exact<{ export type DevSessionDeleteMutation = {devSessionDelete?: {userErrors: {message: string}[]} | null} -export const DevSessionDelete = { +export const DevSessionDeleteDocument = { kind: 'Document', definitions: [ { diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts index 243629fa441..142fae8b5b0 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts @@ -7,6 +7,7 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co export type DevSessionUpdateMutationVariables = Types.Exact<{ appId: Types.Scalars['String']['input'] assetsUrl?: Types.InputMaybe + websocketUrl?: Types.InputMaybe manifest?: Types.InputMaybe inheritedModuleUids: Types.Scalars['String']['input'][] | Types.Scalars['String']['input'] }> @@ -17,7 +18,7 @@ export type DevSessionUpdateMutation = { } | null } -export const DevSessionUpdate = { +export const DevSessionUpdateDocument = { kind: 'Document', definitions: [ { @@ -35,6 +36,11 @@ export const DevSessionUpdate = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, { kind: 'VariableDefinition', variable: {kind: 'Variable', name: {kind: 'Name', value: 'manifest'}}, @@ -69,6 +75,11 @@ export const DevSessionUpdate = { name: {kind: 'Name', value: 'assetsUrl'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'websocketUrl'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, + }, { kind: 'Argument', name: {kind: 'Name', value: 'manifest'}, diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql index d99cc3dddcc..f32b7fb9610 100644 --- a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql @@ -1,5 +1,5 @@ -mutation DevSessionCreate($appId: String!, $assetsUrl: String!) { - devSessionCreate(appId: $appId, assetsUrl: $assetsUrl) { +mutation DevSessionCreate($appId: String!, $assetsUrl: String!, $websocketUrl: String) { + devSessionCreate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl) { userErrors { message on diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql index 6d58ddbf1c9..e0237c71c23 100644 --- a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql @@ -1,5 +1,5 @@ -mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) { - devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) { +mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $websocketUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) { + devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) { userErrors { message on diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index 48f87ac5f97..fd0166ca81c 100644 --- a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */ -import {JsonMapType} from '@shopify/cli-kit/node/toml' - export type Maybe = T | null export type InputMaybe = Maybe export type Exact = {[K in keyof T]: T[K]} @@ -42,8 +40,6 @@ export type Scalars = { ISO8601Date: {input: any; output: any} /** An ISO 8601-encoded datetime */ ISO8601DateTime: {input: any; output: any} - /** Represents untyped JSON */ - JSON: {input: JsonMapType | string; output: JsonMapType} /** The ID for a LegalEntity. */ LegalEntityID: {input: any; output: any} /** The ID for a OrganizationDomain. */ diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 59ec96db0d1..301f1313c4d 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -183,7 +183,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise { abortController = new AbortController() devSessionStatusManager = new DevSessionStatusManager() options = { + app: {} as AppLinkedInterface, + apiKey: 'test-api-key', developerPlatformClient, appWatcher, storeFqdn: 'test.myshopify.com', + url: 'https://test.dev', appId: 'app123', organizationId: 'org123', appPreviewURL: 'https://test.preview.url', @@ -404,6 +407,7 @@ describe('pushUpdatesForDevSession', () => { appId: 'app123', // Assets URL is empty because the affected extension has no assets assetsUrl: undefined, + websocketUrl: 'wss://test.dev/extensions', manifest: { name: 'App', handle: '', @@ -449,6 +453,7 @@ describe('pushUpdatesForDevSession', () => { shopFqdn: 'test.myshopify.com', appId: 'app123', assetsUrl: 'https://gcs.url', + websocketUrl: 'wss://test.dev/extensions', manifest: expect.any(Object), inheritedModuleUids: [], }) @@ -469,8 +474,7 @@ describe('pushUpdatesForDevSession', () => { shopFqdn: 'test.myshopify.com', appId: 'app123', assetsUrl: 'https://gcs.url', - manifest: expect.any(Object), - inheritedModuleUids: [], + websocketUrl: 'wss://test.dev/extensions', }) }) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index 21cbb873a1a..a8f258857a5 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -5,6 +5,7 @@ import {AppEvent, AppEventWatcher, ExtensionEvent} from '../../app-events/app-ev import {compressBundle, getUploadURL, uploadToGCS, writeManifestToBundle} from '../../../bundle.js' import {DevSessionCreateOptions, DevSessionUpdateOptions} from '../../../../utilities/developer-platform-client.js' import {AppManifest} from '../../../../models/app/app.js' +import {getWebSocketUrl} from '../../extension.js' import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime' import {ClientError} from 'graphql-request' import {JsonMapType} from '@shopify/cli-kit/node/toml' @@ -276,16 +277,24 @@ export class DevSession { const {manifest, inheritedModuleUids, assets} = await this.createManifest(appEvent) const signedURL = await this.uploadAssetsIfNeeded(assets, !this.statusManager.status.isReady) - const payload = { - shopFqdn: this.options.storeFqdn, - appId: this.options.appId, - assetsUrl: signedURL, - manifest, - inheritedModuleUids, - } + const websocketUrl = getWebSocketUrl(this.options.url) if (this.statusManager.status.isReady) { + const payload: DevSessionUpdateOptions = { + shopFqdn: this.options.storeFqdn, + appId: this.options.appId, + assetsUrl: signedURL, + manifest, + inheritedModuleUids, + websocketUrl, + } return this.devSessionUpdateWithRetry(payload) } else { + const payload: DevSessionCreateOptions = { + shopFqdn: this.options.storeFqdn, + appId: this.options.appId, + assetsUrl: signedURL, + websocketUrl, + } return this.devSessionCreateWithRetry(payload) } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index f5eb551a756..1c2d87ae21b 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -167,13 +167,14 @@ interface DevSessionSharedOptions { export interface DevSessionCreateOptions extends DevSessionSharedOptions { assetsUrl?: string - manifest: AppManifest + websocketUrl?: string } export interface DevSessionUpdateOptions extends DevSessionSharedOptions { assetsUrl?: string manifest: AppManifest inheritedModuleUids: string[] + websocketUrl?: string } export type DevSessionDeleteOptions = DevSessionSharedOptions diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 1c044c65a68..9a900f1d26f 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -79,13 +79,19 @@ import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_ import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js' import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js' import {CONFIG_EXTENSION_IDS} from '../../models/extensions/extension-instance.js' -import {DevSessionCreate, DevSessionCreateMutation} from '../../api/graphql/app-dev/generated/dev-session-create.js' import { - DevSessionUpdate, + DevSessionCreateDocument as DevSessionCreate, + DevSessionCreateMutation, +} from '../../api/graphql/app-dev/generated/dev-session-create.js' +import { + DevSessionUpdateDocument as DevSessionUpdate, DevSessionUpdateMutation, DevSessionUpdateMutationVariables, } from '../../api/graphql/app-dev/generated/dev-session-update.js' -import {DevSessionDelete, DevSessionDeleteMutation} from '../../api/graphql/app-dev/generated/dev-session-delete.js' +import { + DevSessionDeleteDocument as DevSessionDelete, + DevSessionDeleteMutation, +} from '../../api/graphql/app-dev/generated/dev-session-delete.js' import { FetchStoreByDomain, FetchStoreByDomainQueryVariables, @@ -1017,12 +1023,17 @@ export class AppManagementClient implements DeveloperPlatformClient { return appDeepLink({id, organizationId}) } - async devSessionCreate({appId, assetsUrl, shopFqdn}: DevSessionCreateOptions): Promise { + async devSessionCreate({ + appId, + assetsUrl, + shopFqdn, + websocketUrl, + }: DevSessionCreateOptions): Promise { const appIdNumber = String(numberFromGid(appId)) return this.appDevRequest({ query: DevSessionCreate, shopFqdn, - variables: {appId: appIdNumber, assetsUrl: assetsUrl ?? ''}, + variables: {appId: appIdNumber, assetsUrl: assetsUrl ?? '', websocketUrl}, requestOptions: {requestMode: 'slow-request'}, }) } @@ -1033,6 +1044,7 @@ export class AppManagementClient implements DeveloperPlatformClient { shopFqdn, manifest, inheritedModuleUids, + websocketUrl, }: DevSessionUpdateOptions): Promise { const appIdNumber = String(numberFromGid(appId)) const variables: DevSessionUpdateMutationVariables = { @@ -1040,6 +1052,7 @@ export class AppManagementClient implements DeveloperPlatformClient { assetsUrl, manifest: JSON.stringify(manifest), inheritedModuleUids, + websocketUrl, } return this.appDevRequest({query: DevSessionUpdate, shopFqdn, variables}) } From 4a43067dec37688c268376dc3100e6a89a7c1528 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 19 Jan 2026 15:38:42 +0100 Subject: [PATCH 05/60] Send websocketUrl only on create --- .../app-dev/generated/dev-session-create.ts | 2 +- .../app-dev/generated/dev-session-delete.ts | 2 +- .../app-dev/generated/dev-session-update.ts | 13 +------------ .../app-dev/queries/dev-session-update.graphql | 4 ++-- .../generated/types.d.ts | 4 ++++ .../dev-session/dev-session-process.test.ts | 3 +-- .../dev/processes/dev-session/dev-session.ts | 1 - .../src/cli/utilities/developer-platform-client.ts | 1 - .../app-management-client.ts | 14 +++----------- 9 files changed, 13 insertions(+), 31 deletions(-) diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts index bffec8887ed..32ca3b645b1 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts @@ -16,7 +16,7 @@ export type DevSessionCreateMutation = { } | null } -export const DevSessionCreateDocument = { +export const DevSessionCreate = { kind: 'Document', definitions: [ { diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts index f4ee5554fb8..3f6ccb87cae 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-delete.ts @@ -9,7 +9,7 @@ export type DevSessionDeleteMutationVariables = Types.Exact<{ export type DevSessionDeleteMutation = {devSessionDelete?: {userErrors: {message: string}[]} | null} -export const DevSessionDeleteDocument = { +export const DevSessionDelete = { kind: 'Document', definitions: [ { diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts index 142fae8b5b0..243629fa441 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts @@ -7,7 +7,6 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co export type DevSessionUpdateMutationVariables = Types.Exact<{ appId: Types.Scalars['String']['input'] assetsUrl?: Types.InputMaybe - websocketUrl?: Types.InputMaybe manifest?: Types.InputMaybe inheritedModuleUids: Types.Scalars['String']['input'][] | Types.Scalars['String']['input'] }> @@ -18,7 +17,7 @@ export type DevSessionUpdateMutation = { } | null } -export const DevSessionUpdateDocument = { +export const DevSessionUpdate = { kind: 'Document', definitions: [ { @@ -36,11 +35,6 @@ export const DevSessionUpdateDocument = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, }, - { - kind: 'VariableDefinition', - variable: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, - type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, - }, { kind: 'VariableDefinition', variable: {kind: 'Variable', name: {kind: 'Name', value: 'manifest'}}, @@ -75,11 +69,6 @@ export const DevSessionUpdateDocument = { name: {kind: 'Name', value: 'assetsUrl'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'assetsUrl'}}, }, - { - kind: 'Argument', - name: {kind: 'Name', value: 'websocketUrl'}, - value: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, - }, { kind: 'Argument', name: {kind: 'Name', value: 'manifest'}, diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql index e0237c71c23..6d58ddbf1c9 100644 --- a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql @@ -1,5 +1,5 @@ -mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $websocketUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) { - devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) { +mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) { + devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) { userErrors { message on diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index fd0166ca81c..48f87ac5f97 100644 --- a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' + export type Maybe = T | null export type InputMaybe = Maybe export type Exact = {[K in keyof T]: T[K]} @@ -40,6 +42,8 @@ export type Scalars = { ISO8601Date: {input: any; output: any} /** An ISO 8601-encoded datetime */ ISO8601DateTime: {input: any; output: any} + /** Represents untyped JSON */ + JSON: {input: JsonMapType | string; output: JsonMapType} /** The ID for a LegalEntity. */ LegalEntityID: {input: any; output: any} /** The ID for a OrganizationDomain. */ diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts index ef5fd7b2f27..a07468fb685 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts @@ -407,13 +407,13 @@ describe('pushUpdatesForDevSession', () => { appId: 'app123', // Assets URL is empty because the affected extension has no assets assetsUrl: undefined, - websocketUrl: 'wss://test.dev/extensions', manifest: { name: 'App', handle: '', modules: [ { uid: 'test-ui-extension-uid', + uuid: undefined, assets: 'test-ui-extension-uid', handle: updatedExtension.handle, type: updatedExtension.externalType, @@ -453,7 +453,6 @@ describe('pushUpdatesForDevSession', () => { shopFqdn: 'test.myshopify.com', appId: 'app123', assetsUrl: 'https://gcs.url', - websocketUrl: 'wss://test.dev/extensions', manifest: expect.any(Object), inheritedModuleUids: [], }) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index a8f258857a5..a6b8ad098d8 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -285,7 +285,6 @@ export class DevSession { assetsUrl: signedURL, manifest, inheritedModuleUids, - websocketUrl, } return this.devSessionUpdateWithRetry(payload) } else { diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 1c2d87ae21b..8b27e3b501a 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -174,7 +174,6 @@ export interface DevSessionUpdateOptions extends DevSessionSharedOptions { assetsUrl?: string manifest: AppManifest inheritedModuleUids: string[] - websocketUrl?: string } export type DevSessionDeleteOptions = DevSessionSharedOptions diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 9a900f1d26f..698a796f2c1 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -79,19 +79,13 @@ import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_ import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js' import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js' import {CONFIG_EXTENSION_IDS} from '../../models/extensions/extension-instance.js' +import {DevSessionCreate, DevSessionCreateMutation} from '../../api/graphql/app-dev/generated/dev-session-create.js' import { - DevSessionCreateDocument as DevSessionCreate, - DevSessionCreateMutation, -} from '../../api/graphql/app-dev/generated/dev-session-create.js' -import { - DevSessionUpdateDocument as DevSessionUpdate, + DevSessionUpdate, DevSessionUpdateMutation, DevSessionUpdateMutationVariables, } from '../../api/graphql/app-dev/generated/dev-session-update.js' -import { - DevSessionDeleteDocument as DevSessionDelete, - DevSessionDeleteMutation, -} from '../../api/graphql/app-dev/generated/dev-session-delete.js' +import {DevSessionDelete, DevSessionDeleteMutation} from '../../api/graphql/app-dev/generated/dev-session-delete.js' import { FetchStoreByDomain, FetchStoreByDomainQueryVariables, @@ -1044,7 +1038,6 @@ export class AppManagementClient implements DeveloperPlatformClient { shopFqdn, manifest, inheritedModuleUids, - websocketUrl, }: DevSessionUpdateOptions): Promise { const appIdNumber = String(numberFromGid(appId)) const variables: DevSessionUpdateMutationVariables = { @@ -1052,7 +1045,6 @@ export class AppManagementClient implements DeveloperPlatformClient { assetsUrl, manifest: JSON.stringify(manifest), inheritedModuleUids, - websocketUrl, } return this.appDevRequest({query: DevSessionUpdate, shopFqdn, variables}) } From f903c4780cb978b68826001d5a8598969a373ec3 Mon Sep 17 00:00:00 2001 From: js-goupil Date: Thu, 15 Jan 2026 11:58:08 -0500 Subject: [PATCH 06/60] Added support for supported features in toml --- .changeset/short-cooks-wonder.md | 6 + .../app/src/cli/models/app/app.test-data.ts | 3 + .../app/src/cli/models/app/loader.test.ts | 6 + .../app/src/cli/models/extensions/schemas.ts | 5 + .../specifications/checkout_ui_extension.ts | 1 + .../specifications/ui_extension.test.ts | 113 ++++++++++++++++++ .../extensions/specifications/ui_extension.ts | 1 + .../services/dev/extension/payload.test.ts | 92 ++++++++++++++ .../src/cli/services/dev/extension/payload.ts | 3 + .../services/dev/extension/payload/models.ts | 5 + 10 files changed, 235 insertions(+) create mode 100644 .changeset/short-cooks-wonder.md diff --git a/.changeset/short-cooks-wonder.md b/.changeset/short-cooks-wonder.md new file mode 100644 index 00000000000..76222c7bc21 --- /dev/null +++ b/.changeset/short-cooks-wonder.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': minor +'@shopify/app': minor +--- + +Added CLI support for extensions.supported_features in toml diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index 4049fd80a59..d44964c7a40 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -234,6 +234,9 @@ export async function testUIExtension( sources: [], }, }, + supported_features: { + offline_mode: false, + }, extension_points: [ { target: 'target1', diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 42cf61e9958..bd7f91450b1 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -1483,6 +1483,9 @@ redirect_urls = [ "https://example.com/api/auth" ] [extensions.capabilities.iframe] sources = ["https://my-iframe.com"] + [extensions.supported_features] + offline_mode = true + [extensions.settings] [[extensions.settings.fields]] key = "field_key" @@ -1560,6 +1563,9 @@ redirect_urls = [ "https://example.com/api/auth" ] sources: ['https://my-iframe.com'], }, }, + supported_features: { + offline_mode: true, + }, settings: { fields: [ { diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 5fe195ff3e4..fd8a4c9dcbc 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -28,6 +28,10 @@ const CapabilitiesSchema = zod.object({ iframe: IframeCapabilitySchema.optional(), }) +const SupportedFeaturesSchema = zod.object({ + offline_mode: zod.boolean().optional(), +}) + export const ExtensionsArraySchema = zod.object({ type: zod.string().optional(), extensions: zod.array(zod.any()).optional(), @@ -108,6 +112,7 @@ export const BaseSchema = zod.object({ api_version: ApiVersionSchema.optional(), extension_points: zod.any().optional(), capabilities: CapabilitiesSchema.optional(), + supported_features: SupportedFeaturesSchema.optional(), settings: SettingsSchema.optional(), }) diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts index 39fc3b598a7..f08dfd97c40 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts @@ -26,6 +26,7 @@ const checkoutSpec = createExtensionSpecification({ return { extension_points: config.extension_points, capabilities: config.capabilities, + supported_features: config.supported_features, metafields: config.metafields ?? [], name: config.name, settings: config.settings, diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index 4494db13efd..1e681af82bf 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -978,6 +978,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` ...uiExtension.configuration.capabilities.iframe, }, }, + supported_features: undefined, name: uiExtension.configuration.name, description: uiExtension.configuration.description, api_version: uiExtension.configuration.api_version, @@ -985,6 +986,118 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) }) }) + + test('returns supported_features with offline_mode true when configured', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.spyOn(loadLocales, 'loadLocalesConfig').mockResolvedValue({}) + const configurationPath = joinPath(tmpDir, 'shopify.extension.toml') + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const uiExtension = new ExtensionInstance({ + configuration: { + extension_points: [], + api_version: '2023-01' as const, + name: 'UI Extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + supported_features: { + offline_mode: true, + }, + settings: {}, + }, + directory: tmpDir, + specification, + configurationPath, + entryPath: '', + }) + + // When + const deployConfig = await uiExtension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + // Then + expect(deployConfig?.supported_features).toStrictEqual({ + offline_mode: true, + }) + }) + }) + + test('returns supported_features with offline_mode false when configured', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.spyOn(loadLocales, 'loadLocalesConfig').mockResolvedValue({}) + const configurationPath = joinPath(tmpDir, 'shopify.extension.toml') + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const uiExtension = new ExtensionInstance({ + configuration: { + extension_points: [], + api_version: '2023-01' as const, + name: 'UI Extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + supported_features: { + offline_mode: false, + }, + settings: {}, + }, + directory: tmpDir, + specification, + configurationPath, + entryPath: '', + }) + + // When + const deployConfig = await uiExtension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + // Then + expect(deployConfig?.supported_features).toStrictEqual({ + offline_mode: false, + }) + }) + }) + + test('returns supported_features as undefined when not configured', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.spyOn(loadLocales, 'loadLocalesConfig').mockResolvedValue({}) + const configurationPath = joinPath(tmpDir, 'shopify.extension.toml') + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const uiExtension = new ExtensionInstance({ + configuration: { + extension_points: [], + api_version: '2023-01' as const, + name: 'UI Extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + settings: {}, + }, + directory: tmpDir, + specification, + configurationPath, + entryPath: '', + }) + + // When + const deployConfig = await uiExtension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + // Then + expect(deployConfig?.supported_features).toBeUndefined() + }) + }) }) describe('getBundleExtensionStdinContent()', async () => { diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 0aea517ccf7..a0d0df25f30 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -125,6 +125,7 @@ const uiExtensionSpec = createExtensionSpecification({ api_version: config.api_version, extension_points: transformedExtensionPoints, capabilities: config.capabilities, + supported_features: config.supported_features, name: config.name, description: config.description, settings: config.settings, diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index 44c649fc593..d8080d60a8c 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -421,6 +421,98 @@ describe('getUIExtensionPayload', () => { }) }) + describe('supportedFeatures', () => { + test('returns supportedFeatures with offlineMode true when offline_mode is enabled', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + supported_features: { + offline_mode: true, + }, + extension_points: [], + }, + }) + const options: ExtensionsPayloadStoreOptions = {} as ExtensionsPayloadStoreOptions + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...options, + currentDevelopmentPayload: {}, + }) + + // Then + expect(got.supportedFeatures).toStrictEqual({ + offlineMode: true, + }) + }) + }) + + test('returns supportedFeatures with offlineMode false when offline_mode is disabled', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + supported_features: { + offline_mode: false, + }, + extension_points: [], + }, + }) + const options: ExtensionsPayloadStoreOptions = {} as ExtensionsPayloadStoreOptions + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...options, + currentDevelopmentPayload: {}, + }) + + // Then + expect(got.supportedFeatures).toStrictEqual({ + offlineMode: false, + }) + }) + }) + + test('returns supportedFeatures with offlineMode false when supported_features is not configured', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-extension', + type: 'ui_extension', + metafields: [], + capabilities: {}, + extension_points: [], + }, + }) + const options: ExtensionsPayloadStoreOptions = {} as ExtensionsPayloadStoreOptions + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...options, + currentDevelopmentPayload: {}, + }) + + // Then + expect(got.supportedFeatures).toStrictEqual({ + offlineMode: false, + }) + }) + }) + }) + test('adds root.url, resource.url and surface to extensionPoints[n] when extensionPoints[n] is an object', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 57f9ae3433f..faa7ae2628c 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -44,6 +44,9 @@ export async function getUIExtensionPayload( lastUpdated: (await fileLastUpdatedTimestamp(extensionOutputPath)) ?? 0, }, }, + supportedFeatures: { + offlineMode: extension.configuration.supported_features?.offline_mode ?? false, + }, capabilities: { blockProgress: extension.configuration.capabilities?.block_progress ?? false, networkAccess: extension.configuration.capabilities?.network_access ?? false, 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 dbf0ba53169..0adfcc22c30 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -45,10 +45,15 @@ export interface DevNewExtensionPointSchema extends NewExtensionPointSchemaType } } +interface SupportedFeatures { + offlineMode: boolean +} + export interface UIExtensionPayload { assets: { main: Asset } + supportedFeatures?: SupportedFeatures capabilities?: Capabilities development: { resource: { From 7680a1600326690961cfcfa80f4878ca4c991a9a Mon Sep 17 00:00:00 2001 From: js-goupil Date: Tue, 20 Jan 2026 13:37:27 -0500 Subject: [PATCH 07/60] Added new supportedFeatures to ExtensionPayload --- .changeset/spicy-lemons-beam.md | 5 +++++ packages/ui-extensions-server-kit/src/testing/extensions.ts | 1 + packages/ui-extensions-server-kit/src/types.ts | 5 +++++ 3 files changed, 11 insertions(+) create mode 100644 .changeset/spicy-lemons-beam.md diff --git a/.changeset/spicy-lemons-beam.md b/.changeset/spicy-lemons-beam.md new file mode 100644 index 00000000000..0a3f70d8a3e --- /dev/null +++ b/.changeset/spicy-lemons-beam.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions-server-kit': minor +--- + +Added supportedFeatures to ExtensionPayload diff --git a/packages/ui-extensions-server-kit/src/testing/extensions.ts b/packages/ui-extensions-server-kit/src/testing/extensions.ts index daad527c39b..c3801a09a00 100644 --- a/packages/ui-extensions-server-kit/src/testing/extensions.ts +++ b/packages/ui-extensions-server-kit/src/testing/extensions.ts @@ -54,6 +54,7 @@ export function mockExtension(obj: DeepPartial = {}): Extensio // in a generalized, non-surprising way extensionPoints: obj.extensionPoints as any, capabilities: obj.capabilities as any, + supportedFeatures: obj.supportedFeatures as any, localization: obj.localization as any, authenticatedRedirectStartUrl: obj.authenticatedRedirectStartUrl as any, authenticatedRedirectRedirectUrls: obj.authenticatedRedirectRedirectUrls as any, diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index 9bfa14d1a67..a2084056874 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -143,6 +143,7 @@ export interface ExtensionPayload { handle: string extensionPoints: ExtensionPoints capabilities?: Capabilities + supportedFeatures?: ExtensionSupportedFeatures authenticatedRedirectStartUrl?: string authenticatedRedirectRedirectUrls?: string[] localization?: FlattenedLocalization | Localization | null @@ -158,6 +159,10 @@ export interface ExtensionPayload { } } +export interface ExtensionSupportedFeatures { + offlineMode: boolean +} + export enum Status { Success = 'success', } From 6ca4e2805927cd5dd41d9cb1cdc80402bcc61942 Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Fri, 16 Jan 2026 13:34:15 -0500 Subject: [PATCH 08/60] Expose tools and instructions in the Dev Server payload --- .../cli/models/extensions/specification.ts | 6 + .../specifications/ui_extension.test.ts | 9 + .../extensions/specifications/ui_extension.ts | 67 +++-- .../services/dev/extension/payload.test.ts | 244 ++++++++++-------- .../src/cli/services/dev/extension/payload.ts | 89 +++++-- .../services/dev/extension/payload/models.ts | 2 +- 6 files changed, 262 insertions(+), 155 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index b0eab79677d..d40a17dd115 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -48,6 +48,12 @@ export interface Asset { content: string } +export interface BuildAsset { + filepath: string + module: string + static?: boolean +} + type BuildConfig = | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none'} | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index 4494db13efd..f74aa72e8c7 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -207,6 +207,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [{namespace: 'test', key: 'test'}], default_placement_reference: undefined, @@ -273,6 +274,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: 'PLACEMENT_REFERENCE1', @@ -335,6 +337,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], urls: {}, @@ -397,6 +400,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -462,6 +466,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -529,6 +534,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -596,6 +602,7 @@ describe('ui_extension', async () => { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: './tools.json', + instructions: undefined, metafields: [], default_placement_reference: undefined, capabilities: undefined, @@ -663,6 +670,7 @@ describe('ui_extension', async () => { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: undefined, + instructions: './instructions.md', metafields: [], default_placement_reference: undefined, capabilities: undefined, @@ -890,6 +898,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: './tools.json', + instructions: './instructions.md', metafields: [], default_placement_reference: undefined, capabilities: undefined, diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 0aea517ccf7..f1acb096c8f 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -6,7 +6,7 @@ import { createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' -import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js' +import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, MetafieldSchema} from '../schemas.js' import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js' import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' @@ -27,16 +27,10 @@ const validatePoints = (config: {extension_points?: unknown[]; targeting?: unkno export interface BuildManifest { assets: { // Main asset is always required - [AssetIdentifier.Main]: { - filepath: string - module?: string - } - } & { - [key in AssetIdentifier]?: { - filepath: string - module?: string - static?: boolean - } + [AssetIdentifier.Main]: BuildAsset + [AssetIdentifier.ShouldRender]?: BuildAsset + [AssetIdentifier.Tools]?: BuildAsset + [AssetIdentifier.Instructions]?: BuildAsset } } @@ -88,7 +82,6 @@ export const UIExtensionSchema = BaseSchema.extend({ } return { - tools: targeting.tools, target: targeting.target, module: targeting.module, metafields: targeting.metafields ?? config.metafields ?? [], @@ -97,6 +90,8 @@ export const UIExtensionSchema = BaseSchema.extend({ capabilities: targeting.capabilities, preloads: targeting.preloads ?? {}, build_manifest: buildManifest, + tools: targeting.tools, + instructions: targeting.instructions, } }) return {...config, extension_points: extensionPoints} @@ -147,27 +142,10 @@ const uiExtensionSpec = createExtensionSpecification({ const assets: {[key: string]: Asset} = {} extensionPoints.forEach((extensionPoint) => { - // Start of Selection - Object.entries(extensionPoint.build_manifest.assets).forEach(([identifier, asset]) => { - if (identifier === AssetIdentifier.Main) { - return - } - - // Skip static assets - they are copied after esbuild completes in rebuildContext - if (asset.static && asset.module) { - return - } - - assets[identifier] = { - identifier: identifier as AssetIdentifier, - outputFileName: asset.filepath, - content: shouldIncludeShopifyExtend - ? `import shouldRender from '${asset.module}';shopify.extend('${getShouldRenderTarget( - extensionPoint.target, - )}', (...args) => shouldRender(...args));` - : `import '${asset.module}'`, - } - }) + const shouldRenderAsset = buildShouldRenderAsset(extensionPoint, shouldIncludeShopifyExtend) + if (shouldRenderAsset) { + assets[AssetIdentifier.ShouldRender] = shouldRenderAsset + } }) const assetsArray = Object.values(assets) @@ -180,8 +158,8 @@ const uiExtensionSpec = createExtensionSpecification({ if (!isRemoteDomExtension(config)) return await Promise.all( - config.extension_points.map((extensionPoint) => { - if (!('build_manifest' in extensionPoint)) return Promise.resolve() + config.extension_points.flatMap((extensionPoint) => { + if (!('build_manifest' in extensionPoint)) return [] return Object.entries(extensionPoint.build_manifest.assets).map(([_, asset]) => { if (asset.static && asset.module) { @@ -458,4 +436,23 @@ export function getShouldRenderTarget(target: string) { return target.replace(/\.render$/, '.should-render') } +function buildShouldRenderAsset( + extensionPoint: NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + shouldIncludeShopifyExtend: boolean, +) { + const shouldRenderAsset = extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender] + if (!shouldRenderAsset) { + return + } + return { + identifier: AssetIdentifier.ShouldRender, + outputFileName: shouldRenderAsset.filepath, + content: shouldIncludeShopifyExtend + ? `import shouldRender from '${shouldRenderAsset.module}';shopify.extend('${getShouldRenderTarget( + extensionPoint.target, + )}', (...args) => shouldRender(...args));` + : `import '${shouldRenderAsset.module}'`, + } +} + export default uiExtensionSpec diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index 44c649fc593..a01fce3af9f 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -3,23 +3,45 @@ import {getUIExtensionPayload} from './payload.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {testUIExtension} from '../../../models/app/app.test-data.js' import * as appModel from '../../../models/app/app.js' -import {describe, expect, test, vi} from 'vitest' -import {inTemporaryDirectory, touchFile} from '@shopify/cli-kit/node/fs' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {inTemporaryDirectory, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' describe('getUIExtensionPayload', () => { + beforeEach(() => { + vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ + name: 'extension-renderer', + version: '1.2.3', + }) + }) + + function createMockOptions(tmpDir: string, extensions: any[]): Omit { + return { + signal: vi.fn() as any, + stdout: vi.fn() as any, + stderr: vi.fn() as any, + apiKey: 'api-key', + appName: 'foobar', + appDirectory: '/tmp', + extensions, + grantedScopes: ['scope-a'], + port: 123, + url: 'http://tunnel-url.com', + storeFqdn: 'my-domain.com', + storeId: '123456789', + buildDirectory: tmpDir, + checkoutCartUrl: 'https://my-domain.com/cart', + subscriptionProductUrl: 'https://my-domain.com/subscription', + manifestVersion: '3', + websocketURL: 'wss://mock.url/extensions', + } + } + test('returns the right payload', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const outputPath = joinPath(tmpDir, 'test-ui-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const uiExtension = await testUIExtension({ outputPath, @@ -45,34 +67,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [uiExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -126,13 +124,6 @@ describe('getUIExtensionPayload', () => { // Given const outputPath = joinPath(tmpDir, 'test-ui-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const buildManifest = { assets: { @@ -174,34 +165,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [uiExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -267,18 +234,115 @@ describe('getUIExtensionPayload', () => { }) }) + test('returns the right payload for UI Extensions with tools in build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + await writeFile(joinPath(tmpDir, 'tools.json'), '{"tools": []}') + + const buildManifest = { + assets: { + main: {module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + tools: {module: './tools.json', filepath: '/test-ui-extension-tools.json', static: true}, + }, + } + + const uiExtension = await testUIExtension({ + outputPath, + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [{target: 'CUSTOM_EXTENSION_POINT', build_manifest: buildManifest}], + }, + devUUID: 'devUUID', + }) + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + // Then + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + main: { + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + lastUpdated: expect.any(Number), + }, + tools: { + name: 'tools', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-tools.json', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + + test('returns the right payload for UI Extensions with instructions in build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + await writeFile(joinPath(tmpDir, 'instructions.md'), '# Instructions') + + const buildManifest = { + assets: { + main: {module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + instructions: {module: './instructions.md', filepath: '/test-ui-extension-instructions.md', static: true}, + }, + } + + const uiExtension = await testUIExtension({ + outputPath, + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [{target: 'CUSTOM_EXTENSION_POINT', build_manifest: buildManifest}], + }, + devUUID: 'devUUID', + }) + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + // Then + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + main: { + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + lastUpdated: expect.any(Number), + }, + instructions: { + name: 'instructions', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-instructions.md', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + test('returns the right payload for post-purchase extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const outputPath = joinPath(tmpDir, 'test-post-purchase-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const postPurchaseExtension = await testUIExtension({ outputPath, @@ -308,34 +372,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [postPurchaseExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(postPurchaseExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [postPurchaseExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -422,7 +462,7 @@ describe('getUIExtensionPayload', () => { }) test('adds root.url, resource.url and surface to extensionPoints[n] when extensionPoints[n] is an object', async () => { - await inTemporaryDirectory(async (tmpDir) => { + await inTemporaryDirectory(async (_tmpDir) => { // Given const uiExtension = await testUIExtension({ devUUID: 'devUUID', diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 57f9ae3433f..b33279a7cbd 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -1,11 +1,13 @@ import {getLocalization} from './localization.js' -import {Asset, DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' +import {DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' import {getExtensionPointTargetSurface} from './utilities.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {getUIExtensionResourceURL} from '../../../utilities/extensions/configuration.js' import {getUIExtensionRendererVersion} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' +import {BuildAsset} from '../../../models/extensions/specification.js' +import {NewExtensionPointSchemaType} from '../../../models/extensions/schemas.js' import {fileLastUpdatedTimestamp} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {dirname, joinPath} from '@shopify/cli-kit/node/path' @@ -15,6 +17,13 @@ export type GetUIExtensionPayloadOptions = Omit { const {target, resource} = extensionPoint - return { + const payload = { ...extensionPoint, - ...(extensionPoint.build_manifest - ? {assets: await extractAssetsFromBuildManifest(extensionPoint.build_manifest, url, extension)} - : {}), surface: getExtensionPointTargetSurface(target), root: { url: `${url}/${target}`, }, resource: resource || {url: ''}, } + + if (!('build_manifest' in extensionPoint)) { + return payload + } + + return { + ...payload, + ...(await mapBuildManifestToPayload( + extensionPoint.build_manifest, + extensionPoint as NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + url, + extension, + )), + } }), ) } @@ -123,20 +143,47 @@ async function getExtensionPoints(extension: ExtensionInstance, url: string) { return extensionPoints } -async function extractAssetsFromBuildManifest(buildManifest: BuildManifest, url: string, extension: ExtensionInstance) { - if (!buildManifest?.assets) return {} - const assets: {[key: string]: Asset} = {} - - for (const [name, asset] of Object.entries(buildManifest.assets)) { - assets[name] = { - name, - url: `${url}${joinPath('/assets/', asset.filepath)}`, - // eslint-disable-next-line no-await-in-loop - lastUpdated: (await fileLastUpdatedTimestamp(joinPath(dirname(extension.outputPath), asset.filepath))) ?? 0, - } +/** + * Default asset mapper - adds asset to the assets object + */ +async function defaultAssetMapper({ + identifier, + asset, + url, + extension, +}: AssetMapperContext): Promise> { + const payload = await getAssetPayload(identifier, asset, url, extension) + return { + assets: {[payload.name]: payload}, } +} + +/** + * Maps build manifest assets to payload format + * Each mapper returns a partial that gets merged into the extension point + */ +async function mapBuildManifestToPayload( + buildManifest: BuildManifest, + _extensionPoint: NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + url: string, + extension: ExtensionInstance, +): Promise> { + if (!buildManifest?.assets) return {} + + const mappingResults = await Promise.all( + Object.entries(buildManifest.assets).map(async ([identifier, asset]) => { + return defaultAssetMapper({identifier, asset, url, extension}) + }), + ) - return assets + return mappingResults.reduce>( + (acc, result) => ({ + ...acc, + ...result, + assets: {...acc.assets, ...result.assets}, + }), + {}, + ) } export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionPoints is DevNewExtensionPointSchema[] { @@ -145,3 +192,11 @@ export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionP extensionPoints.every((extensionPoint: unknown) => typeof extensionPoint === 'object') ) } + +async function getAssetPayload(name: string, asset: BuildAsset, url: string, extension: ExtensionInstance) { + return { + name, + url: `${url}${joinPath('/assets/', asset.filepath)}`, + lastUpdated: (await fileLastUpdatedTimestamp(joinPath(dirname(extension.outputPath), asset.filepath))) ?? 0, + } +} 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 dbf0ba53169..e132629e6fc 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -26,7 +26,7 @@ export interface ExtensionsEndpointPayload extends ExtensionsPayloadInterface { url: string } } -export interface Asset { +interface Asset { name: string url: string lastUpdated: number From e67a4a5ba3c69229adf4ea865b23ae6c2428bebc Mon Sep 17 00:00:00 2001 From: js-goupil Date: Tue, 20 Jan 2026 14:06:49 -0500 Subject: [PATCH 09/60] Supported features non optional --- packages/ui-extensions-server-kit/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index a2084056874..ed27db533fc 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -143,7 +143,7 @@ export interface ExtensionPayload { handle: string extensionPoints: ExtensionPoints capabilities?: Capabilities - supportedFeatures?: ExtensionSupportedFeatures + supportedFeatures: ExtensionSupportedFeatures authenticatedRedirectStartUrl?: string authenticatedRedirectRedirectUrls?: string[] localization?: FlattenedLocalization | Localization | null From 13470559e5c3bbd1575acd7f01cde1082a616cf1 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:01:14 -0500 Subject: [PATCH 10/60] Make --no-release exclusive from new --allow flags on app deploy --- packages/app/src/cli/commands/app/deploy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/cli/commands/app/deploy.ts b/packages/app/src/cli/commands/app/deploy.ts index 1f169c0b98d..1f7ec37618b 100644 --- a/packages/app/src/cli/commands/app/deploy.ts +++ b/packages/app/src/cli/commands/app/deploy.ts @@ -49,6 +49,7 @@ export default class Deploy extends AppLinkedCommand { "Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.", env: 'SHOPIFY_FLAG_NO_RELEASE', default: false, + exclusive: ['allow-updates', 'allow-deletes'], }), 'no-build': Flags.boolean({ description: From b105cfdf62d7a50007715003a01f0f8e6e49e3f8 Mon Sep 17 00:00:00 2001 From: Nick Wesselman <27013789+nickwesselman@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:09:53 -0500 Subject: [PATCH 11/60] refresh manifests --- packages/cli/README.md | 4 ++-- packages/cli/oclif.manifest.json | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 407a03ae468..0b10237df25 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -200,8 +200,8 @@ Deploy your Shopify app. ``` USAGE - $ shopify app deploy [--allow-deletes] [--allow-updates] [--client-id | -c ] [-f] [--message - ] [--no-build] [--no-color] [--no-release] [--path ] [--reset | ] [--source-control-url ] + $ shopify app deploy [--client-id | -c ] [-f] [--message ] [--no-build] [--no-color] + [--no-release | --allow-updates | --allow-deletes] [--path ] [--reset | ] [--source-control-url ] [--verbose] [--version ] FLAGS diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ed6c6cb53c4..e1df719bc35 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -744,6 +744,10 @@ "allowNo": false, "description": "Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.", "env": "SHOPIFY_FLAG_NO_RELEASE", + "exclusive": [ + "allow-updates", + "allow-deletes" + ], "hidden": false, "name": "no-release", "type": "boolean" From d935cfb3f04c9e06ece54be1ce4455d9961fd2f9 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 21 Jan 2026 11:07:40 +0100 Subject: [PATCH 12/60] =?UTF-8?q?Fix=20`shopify=20theme=20dev=20--theme-ed?= =?UTF-8?q?itor-sync`=20so=20it=20doesn=E2=80=99t=20delete=20files=20when?= =?UTF-8?q?=20they=E2=80=99re=20updated=20by=20AI=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/pink-moles-leave.md | 5 ++++ .../theme-environment/theme-polling.test.ts | 27 +++++++++++++++++++ .../theme-environment/theme-polling.ts | 22 ++++++++++----- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 .changeset/pink-moles-leave.md diff --git a/.changeset/pink-moles-leave.md b/.changeset/pink-moles-leave.md new file mode 100644 index 00000000000..c07cc6cee12 --- /dev/null +++ b/.changeset/pink-moles-leave.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Fix `shopify theme dev --theme-editor-sync` so it doesn’t delete files when they’re updated by AI agents diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-polling.test.ts b/packages/theme/src/cli/utilities/theme-environment/theme-polling.test.ts index 02b893d241c..4576fba4a98 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-polling.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-polling.test.ts @@ -266,6 +266,33 @@ describe('pollRemoteJsonChanges', async () => { expect(fetchThemeAssets).toHaveBeenCalledWith(1, ['templates/asset1.json', 'templates/asset3.json'], adminSession) expect(fetchThemeAssets).not.toHaveBeenCalledWith(1, ['templates/asset2.json'], adminSession) }) + + test('does not delete file when it becomes unsynced during fetchChecksums call (race condition)', async () => { + // Given + const files = new Map([ + ['templates/index.json', {checksum: '1', key: 'templates/index.json', value: '{}'}], + ]) + const themeFileSystem = { + ...fakeThemeFileSystem('tmp', files), + unsyncedFileKeys: new Set(), + } + const deleteSpy = vi.spyOn(themeFileSystem, 'delete') + const remoteChecksums = [{checksum: '1', key: 'templates/index.json'}] + const updatedRemoteChecksums = [{checksum: '1', key: 'templates/index.json'}] + + // When + // Simulate the race condition: file becomes unsynced during fetchChecksums + vi.mocked(fetchChecksums).mockImplementation(async () => { + themeFileSystem.unsyncedFileKeys.add('templates/index.json') + return updatedRemoteChecksums + }) + + await pollRemoteJsonChanges(developmentTheme, adminSession, remoteChecksums, themeFileSystem, defaultOptions) + + // Then + expect(deleteSpy).not.toHaveBeenCalled() + expect(themeFileSystem.files.get('templates/index.json')).toBeDefined() + }) }) describe('deleteRemovedAssets', () => { diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts b/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts index 342e0f2a9bd..c8de4126687 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-polling.ts @@ -80,9 +80,21 @@ export async function pollRemoteJsonChanges( localFileSystem: ThemeFileSystem, options: PollingOptions, ): Promise { - const previousChecksums = applyFileFilters(remoteChecksums, localFileSystem) + /* + * Capture the current set of unsynced file keys to ensure + * consistent filtering between previousChecksums and + * latestChecksums. + * + * This prevents a race condition where a file saved locally + * during fetchChecksums() would be filtered out of + * latestChecksums but not previousChecksums, causing the file + * to incorrectly be deleted. + */ + const currentUnsyncedKeys = new Set(localFileSystem.unsyncedFileKeys) + + const previousChecksums = applyFileFilters(remoteChecksums, localFileSystem, currentUnsyncedKeys) const latestChecksums = await fetchChecksums(targetTheme.id, currentSession).then((checksums) => - applyFileFilters(checksums, localFileSystem), + applyFileFilters(checksums, localFileSystem, currentUnsyncedKeys), ) const changedAssets = getAssetsChangedOnRemote(previousChecksums, latestChecksums) @@ -189,9 +201,7 @@ async function abortIfMultipleSourcesChange(localFileSystem: ThemeFileSystem, as } } -function applyFileFilters(files: Checksum[], localThemeFileSystem: ThemeFileSystem) { +function applyFileFilters(files: Checksum[], localThemeFileSystem: ThemeFileSystem, unsyncedKeys: Set) { const filteredFiles = localThemeFileSystem.applyIgnoreFilters(files) - return filteredFiles - .filter((file) => file.key.endsWith('.json')) - .filter((file) => !localThemeFileSystem.unsyncedFileKeys.has(file.key)) + return filteredFiles.filter((file) => file.key.endsWith('.json')).filter((file) => !unsyncedKeys.has(file.key)) } From 7403805a6c684dcdf09f2a9cb54df4e29b467baa Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 21 Jan 2026 14:23:32 +0100 Subject: [PATCH 13/60] Skip local dev console with SHOPIFY_SKIP_LOCAL_DEV_CONSOLE --- .../dev-session/dev-session-process.test.ts | 34 +++++++++++++++++-- .../dev/processes/dev-session/dev-session.ts | 7 ++-- .../dev/processes/setup-dev-processes.test.ts | 7 ++++ .../dev/processes/setup-dev-processes.ts | 6 +++- .../cli-kit/src/private/node/constants.ts | 1 + .../cli-kit/src/public/node/context/local.ts | 10 ++++++ 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts index a07468fb685..f51e4be5a72 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts @@ -20,6 +20,7 @@ import {AbortSignal, AbortController} from '@shopify/cli-kit/node/abort' import {flushPromises} from '@shopify/cli-kit/node/promises' import * as outputContext from '@shopify/cli-kit/node/ui/components' import {readdir} from '@shopify/cli-kit/node/fs' +import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/archiver') @@ -27,6 +28,14 @@ vi.mock('@shopify/cli-kit/node/http') vi.mock('../../../../utilities/app/app-url.js') vi.mock('node-fetch') vi.mock('../../../bundle.js') +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + firstPartyDev: vi.fn().mockReturnValue(false), + skipLocalDevConsole: vi.fn().mockReturnValue(false), + } +}) describe('setupDevSessionProcess', () => { test('returns a dev session process with correct configuration', async () => { @@ -210,8 +219,10 @@ describe('pushUpdatesForDevSession', () => { contextSpy.mockRestore() }) - test('updates preview URL when extension is previewable', async () => { - // Given + test('updates preview URL to appLocalProxyURL when extension is previewable (dev console shown by default)', async () => { + // Given - dev console is shown by default when skipLocalDevConsole is false + vi.mocked(firstPartyDev).mockReturnValue(false) + vi.mocked(skipLocalDevConsole).mockReturnValue(false) const extension = await testUIExtension({type: 'ui_extension'}) const newApp = testAppLinked({allExtensions: [extension]}) @@ -226,6 +237,25 @@ describe('pushUpdatesForDevSession', () => { expect(devSessionStatusManager.status.previewURL).toBe(options.appLocalProxyURL) }) + test('updates preview URL to appPreviewURL when both skip conditions are met', async () => { + // Given - dev console is skipped only when !firstPartyDev() AND skipLocalDevConsole() + vi.mocked(firstPartyDev).mockReturnValue(false) + vi.mocked(skipLocalDevConsole).mockReturnValue(true) + const extension = await testUIExtension({type: 'ui_extension'}) + const newApp = testAppLinked({allExtensions: [extension]}) + + // When + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + appWatcher.emit('all', {app: newApp, extensionEvents: [{type: 'updated', extension}]}) + await flushPromises() + + // Then + expect(devSessionStatusManager.status.previewURL).toBe(options.appPreviewURL) + vi.mocked(skipLocalDevConsole).mockReturnValue(false) + }) + test('updates preview URL to appPreviewURL when no previewable extensions', async () => { // Given const extension = await testFlowActionExtension() diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index a6b8ad098d8..82b3b89f802 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -10,7 +10,7 @@ import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime' import {ClientError} from 'graphql-request' import {JsonMapType} from '@shopify/cli-kit/node/toml' import {AbortError} from '@shopify/cli-kit/node/error' -import {isUnitTest} from '@shopify/cli-kit/node/context/local' +import {firstPartyDev, isUnitTest, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {readdir} from '@shopify/cli-kit/node/fs' import {SerialBatchProcessor} from '@shopify/cli-kit/node/serial-batch-processor' @@ -240,11 +240,14 @@ export class DevSession { /** * Update the preview URL, it only changes if we move between a non-previewable state and a previewable state. * (i.e. if we go from a state with no extensions to a state with ui-extensions or vice versa) + * Skip the dev console only when BOTH: SHOPIFY_CLI_1P_DEV is NOT enabled AND SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is set. * @param event - The app event */ private updatePreviewURL(event: AppEvent) { const hasPreview = event.app.allExtensions.filter((ext) => ext.isPreviewable).length > 0 - const newPreviewURL = hasPreview ? this.options.appLocalProxyURL : this.options.appPreviewURL + const skipDevConsole = !firstPartyDev() && skipLocalDevConsole() + const useDevConsole = !skipDevConsole && hasPreview + const newPreviewURL = useDevConsole ? this.options.appLocalProxyURL : this.options.appPreviewURL this.statusManager.updateStatus({previewURL: newPreviewURL}) } diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index 12bfc659c4e..4b8a460a231 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -35,6 +35,7 @@ import {Config} from '@oclif/core' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' import {isStorefrontPasswordProtected} from '@shopify/theme' import {fetchTheme} from '@shopify/cli-kit/node/themes/api' +import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' vi.mock('../../context/identifiers.js') vi.mock('@shopify/cli-kit/node/session.js') @@ -42,6 +43,7 @@ vi.mock('../fetch.js') vi.mock('@shopify/cli-kit/node/environment') vi.mock('@shopify/theme') vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/context/local') beforeEach(() => { // mocked for draft extensions @@ -67,6 +69,10 @@ beforeEach(() => { role: 'theme', processing: false, }) + // By default, firstPartyDev is false (dev console URL only used when enabled) + vi.mocked(firstPartyDev).mockReturnValue(false) + // By default, skipLocalDevConsole is false + vi.mocked(skipLocalDevConsole).mockReturnValue(false) }) const appContextResult = { @@ -162,6 +168,7 @@ describe('setup-dev-processes', () => { graphiqlKey, }) + // Dev console is shown by default (only skipped when !firstPartyDev() AND skipLocalDevConsole()) expect(res.previewUrl).toBe('https://example.com/proxy/extensions/dev-console') expect(res.processes[0]).toMatchObject({ type: 'web', 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 1f5821ff9e5..81cf8500626 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 @@ -22,6 +22,7 @@ import {AppEventWatcher} from '../app-events/app-event-watcher.js' import {reloadApp} from '../../../models/app/loader.js' import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' import {isTruthy} from '@shopify/cli-kit/node/context/utilities' +import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' import {outputInfo} from '@shopify/cli-kit/node/output' @@ -99,9 +100,12 @@ export async function setupDevProcesses({ const appWatcher = new AppEventWatcher(reloadedApp, network.proxyUrl) // Decide on the appropriate preview URL for a session with these processes + // Skip the dev console only when BOTH: SHOPIFY_CLI_1P_DEV is NOT enabled AND SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is set const anyPreviewableExtensions = reloadedApp.allExtensions.some((ext) => ext.isPreviewable) const devConsoleURL = `${network.proxyUrl}/extensions/dev-console` - const previewURL = anyPreviewableExtensions ? devConsoleURL : appPreviewUrl + const skipDevConsole = !firstPartyDev() && skipLocalDevConsole() + const useDevConsole = !skipDevConsole && anyPreviewableExtensions + const previewURL = useDevConsole ? devConsoleURL : appPreviewUrl const graphiqlURL = shouldRenderGraphiQL ? `http://localhost:${graphiqlPort}/graphiql${graphiqlKey ? `?key=${graphiqlKey}` : ''}` diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index d76fba74700..6dbe539f8f3 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -19,6 +19,7 @@ export const environmentVariables = { enableCliRedirect: 'SHOPIFY_CLI_ENABLE_CLI_REDIRECT', env: 'SHOPIFY_CLI_ENV', firstPartyDev: 'SHOPIFY_CLI_1P_DEV', + skipLocalDevConsole: 'SHOPIFY_SKIP_LOCAL_DEV_CONSOLE', noAnalytics: 'SHOPIFY_CLI_NO_ANALYTICS', partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN', runAsUser: 'SHOPIFY_RUN_AS_USER', diff --git a/packages/cli-kit/src/public/node/context/local.ts b/packages/cli-kit/src/public/node/context/local.ts index ce1afcffc57..fde5bc4e490 100644 --- a/packages/cli-kit/src/public/node/context/local.ts +++ b/packages/cli-kit/src/public/node/context/local.ts @@ -112,6 +112,16 @@ export function firstPartyDev(env = process.env): boolean { return isTruthy(env[environmentVariables.firstPartyDev]) } +/** + * Returns true if the local dev console should be skipped. + * + * @param env - The environment variables from the environment of the current process. + * @returns True if SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is truthy. + */ +export function skipLocalDevConsole(env = process.env): boolean { + return isTruthy(env[environmentVariables.skipLocalDevConsole]) +} + /** * Return gitpodURL if we are running in gitpod. * Https://www.gitpod.io/docs/environment-variables#default-environment-variables. From 1ad70944ef495e85e1b3c5eaa67ce1ff019ae49a Mon Sep 17 00:00:00 2001 From: Jordan Verasamy Date: Wed, 21 Jan 2026 16:06:38 -0800 Subject: [PATCH 14/60] remove extra newline from bulk mutation variables --- .../app/src/cli/services/bulk-operations/stage-file.test.ts | 3 +-- packages/app/src/cli/services/bulk-operations/stage-file.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts index 5a1d6e6978d..b342a7bf52a 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.test.ts +++ b/packages/app/src/cli/services/bulk-operations/stage-file.test.ts @@ -69,7 +69,7 @@ describe('stageFile', () => { const uploadedBlob = fileAppendCall?.[1] as Blob const uploadedContent = await uploadedBlob?.text() - expect(uploadedContent).toBe('{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}\n') + expect(uploadedContent).toBe('{"input":{"id":"gid://shopify/Product/123","tags":["test"]}}') }) test('handles JSONL with multiple lines correctly', async () => { @@ -94,7 +94,6 @@ describe('stageFile', () => { '{"input":{"id":"gid://shopify/Product/1","title":"New Shirt"}}', '{"input":{"id":"gid://shopify/Product/2","title":"Cool Pants"}}', '{"input":{"id":"gid://shopify/Product/3","title":"Nice Hat"}}', - '', ].join('\n') expect(uploadedContent).toBe(expectedContent) diff --git a/packages/app/src/cli/services/bulk-operations/stage-file.ts b/packages/app/src/cli/services/bulk-operations/stage-file.ts index 6a11e98e5af..ab90ef4fe95 100644 --- a/packages/app/src/cli/services/bulk-operations/stage-file.ts +++ b/packages/app/src/cli/services/bulk-operations/stage-file.ts @@ -18,7 +18,7 @@ interface StageFileOptions { export async function stageFile(options: StageFileOptions): Promise { const {adminSession, variablesJsonl} = options - const buffer = Buffer.from(variablesJsonl ? `${variablesJsonl}\n` : '', 'utf-8') + const buffer = Buffer.from(variablesJsonl ?? '', 'utf-8') const filename = 'bulk-variables.jsonl' const size = buffer.length From b78c6390461a5765e0350e13726429e6bdb2f7d3 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 22 Jan 2026 09:31:41 +0100 Subject: [PATCH 15/60] Update commit message --- .changeset/pink-moles-leave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pink-moles-leave.md b/.changeset/pink-moles-leave.md index c07cc6cee12..c6aabb802bd 100644 --- a/.changeset/pink-moles-leave.md +++ b/.changeset/pink-moles-leave.md @@ -2,4 +2,4 @@ '@shopify/theme': patch --- -Fix `shopify theme dev --theme-editor-sync` so it doesn’t delete files when they’re updated by AI agents +Fix `shopify theme dev --theme-editor-sync` to avoid deleting files during race conditions, especially when multiple changes come from an external process (e.g., AI coding tools) From cf3caf4c1865a3e12a5ef6a312f1cec08a895a51 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 21 Jan 2026 15:00:11 -0800 Subject: [PATCH 16/60] fix-error-message-with-empty-query --- .../api/graphql/admin/generated/types.d.ts | 2 -- .../bulk-operations/generated/types.d.ts | 2 -- .../utilities/execute-command-helpers.test.ts | 30 +++++++++++++++++++ .../cli/utilities/execute-command-helpers.ts | 12 +++++++- .../api/graphql/admin/generated/types.d.ts | 2 -- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/api/graphql/admin/generated/types.d.ts b/packages/app/src/cli/api/graphql/admin/generated/types.d.ts index a15582d7847..98ff1c678d8 100644 --- a/packages/app/src/cli/api/graphql/admin/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/admin/generated/types.d.ts @@ -89,8 +89,6 @@ export type Scalars = { JSON: {input: JsonMapType | string; output: JsonMapType} /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ Money: {input: any; output: any} - /** A scalar value. */ - Scalar: {input: any; output: any} /** * Represents a unique identifier in the Storefront API. A `StorefrontID` value can * be used wherever an ID is expected in the Storefront API. diff --git a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts index f52c0a5a9a9..c68fb733477 100644 --- a/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/bulk-operations/generated/types.d.ts @@ -89,8 +89,6 @@ export type Scalars = { JSON: {input: JsonMapType | string; output: JsonMapType} /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ Money: {input: any; output: any} - /** A scalar value. */ - Scalar: {input: any; output: any} /** * Represents a unique identifier in the Storefront API. A `StorefrontID` value can * be used wherever an ID is expected in the Storefront API. diff --git a/packages/app/src/cli/utilities/execute-command-helpers.test.ts b/packages/app/src/cli/utilities/execute-command-helpers.test.ts index f3d0f685562..5d31edc2378 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.test.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.test.ts @@ -130,6 +130,18 @@ describe('prepareExecuteContext', () => { await expect(prepareExecuteContext(flagsWithoutQuery)).rejects.toThrow('exactlyOne constraint') }) + test('throws AbortError when query flag is empty string', async () => { + const flagsWithEmptyQuery = {...mockFlags, query: ''} + + await expect(prepareExecuteContext(flagsWithEmptyQuery)).rejects.toThrow('--query flag value is empty') + }) + + test('throws AbortError when query flag contains only whitespace', async () => { + const flagsWithWhitespaceQuery = {...mockFlags, query: ' \n\t '} + + await expect(prepareExecuteContext(flagsWithWhitespaceQuery)).rejects.toThrow('--query flag value is empty') + }) + test('returns query, app context, and store', async () => { const result = await prepareExecuteContext(mockFlags) @@ -169,6 +181,24 @@ describe('prepareExecuteContext', () => { expect(readFile).not.toHaveBeenCalled() }) + test('throws AbortError when query file is empty', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue('' as any) + + const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/empty.graphql'} + + await expect(prepareExecuteContext(flagsWithQueryFile)).rejects.toThrow('is empty') + }) + + test('throws AbortError when query file contains only whitespace', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue(' \n\t ' as any) + + const flagsWithQueryFile = {...mockFlags, query: undefined, 'query-file': '/path/to/whitespace.graphql'} + + await expect(prepareExecuteContext(flagsWithQueryFile)).rejects.toThrow('is empty') + }) + test('validates GraphQL query using validateSingleOperation', async () => { await prepareExecuteContext(mockFlags) diff --git a/packages/app/src/cli/utilities/execute-command-helpers.ts b/packages/app/src/cli/utilities/execute-command-helpers.ts index 4e5f2f4a00e..a4602022b60 100644 --- a/packages/app/src/cli/utilities/execute-command-helpers.ts +++ b/packages/app/src/cli/utilities/execute-command-helpers.ts @@ -63,7 +63,10 @@ export async function prepareAppStoreContext(flags: AppStoreContextFlags): Promi export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise { let query: string | undefined - if (flags.query) { + if (flags.query !== undefined) { + if (!flags.query.trim()) { + throw new AbortError('The --query flag value is empty. Please provide a valid GraphQL query or mutation.') + } query = flags.query } else if (flags['query-file']) { const queryFile = flags['query-file'] @@ -73,6 +76,13 @@ export async function prepareExecuteContext(flags: ExecuteCommandFlags): Promise ) } query = await readFile(queryFile, {encoding: 'utf8'}) + if (!query.trim()) { + throw new AbortError( + outputContent`Query file at ${outputToken.path( + queryFile, + )} is empty. Please provide a valid GraphQL query or mutation.`, + ) + } } if (!query) { diff --git a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts index da424d13e6f..3ecac10dacd 100644 --- a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts @@ -89,8 +89,6 @@ export type Scalars = { JSON: {input: JsonMapType | string; output: JsonMapType} /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ Money: {input: any; output: any} - /** A scalar value. */ - Scalar: {input: any; output: any} /** * Represents a unique identifier in the Storefront API. A `StorefrontID` value can * be used wherever an ID is expected in the Storefront API. From 947025f6bb2307469d5e3fff2a6b2f99654151c9 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 23 Jan 2026 11:13:24 +0100 Subject: [PATCH 17/60] =?UTF-8?q?Fix=20the=20default=20environments=20infr?= =?UTF-8?q?astructure=20so=20it=20doesn=E2=80=99t=20fail=20when=20running?= =?UTF-8?q?=20commands=20that=20don=E2=80=99t=20require=20authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/crisp-loops-start.md | 5 ++ .../src/cli/utilities/theme-command.test.ts | 81 +++++++++++++++++++ .../theme/src/cli/utilities/theme-command.ts | 10 ++- 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 .changeset/crisp-loops-start.md diff --git a/.changeset/crisp-loops-start.md b/.changeset/crisp-loops-start.md new file mode 100644 index 00000000000..70791e36728 --- /dev/null +++ b/.changeset/crisp-loops-start.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Fix the default environments infrastructure so it doesn’t fail when running commands that don't require authentication diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index c4492d8d8e1..15a71e6aae9 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -126,6 +126,46 @@ class TestNoMultiEnvThemeCommand extends TestThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null } +class TestThemeCommandWithoutStoreRequired extends ThemeCommand { + static flags = { + environment: Flags.string({ + multiple: true, + default: [], + env: 'SHOPIFY_FLAG_ENVIRONMENT', + }), + path: Flags.string({ + env: 'SHOPIFY_FLAG_PATH', + default: 'current/working/directory', + }), + password: Flags.string({ + env: 'SHOPIFY_FLAG_PASSWORD', + }), + store: Flags.string({ + env: 'SHOPIFY_FLAG_STORE', + }), + } + + static multiEnvironmentsFlags: RequiredFlags = ['path'] + + commandCalls: { + flags: any + session: AdminSession | undefined + multiEnvironment?: boolean + args?: any + context?: any + }[] = [] + + async command( + flags: any, + session: AdminSession | undefined, + multiEnvironment?: boolean, + args?: any, + context?: {stdout?: Writable; stderr?: Writable}, + ): Promise { + this.commandCalls.push({flags, session, multiEnvironment, args, context}) + } +} + describe('ThemeCommand', () => { let mockSession: AdminSession @@ -203,6 +243,47 @@ describe('ThemeCommand', () => { await expect(command.run()).rejects.toThrow('Please provide a valid environment.') }) + test('single environment provided without store - does not throw when store is not required', async () => { + // Given + const environmentConfig = {path: '/some/path'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]).toMatchObject({ + flags: { + environment: ['development'], + path: '/some/path', + }, + session: undefined, + multiEnvironment: false, + }) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('single environment provided with store - creates session when store is provided even if not required', async () => { + // Given + const environmentConfig = {path: '/some/path', store: 'store.myshopify.com'} + vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) + + await CommandConfig.load() + const command = new TestThemeCommandWithoutStoreRequired(['--environment', 'development'], CommandConfig) + + // When + await command.run() + + // Then + expect(ensureAuthenticatedThemes).toHaveBeenCalledOnce() + expect(command.commandCalls).toHaveLength(1) + expect(command.commandCalls[0]?.session).toEqual(mockSession) + }) + test('multiple environments provided - uses renderConcurrent for parallel execution', async () => { // Given vi.mocked(loadEnvironment) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index b03a3fa5159..52dc6171993 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -77,13 +77,19 @@ export default abstract class ThemeCommand extends Command { const environments = (Array.isArray(flags.environment) ? flags.environment : [flags.environment]).filter(Boolean) + // Check if store flag is required by the command + const storeIsRequired = + requiredFlags !== null && + requiredFlags.some((flag) => (Array.isArray(flag) ? flag.includes('store') : flag === 'store')) + // Single environment or no environment if (environments.length <= 1) { - if (environments[0] && !flags.store) { + if (environments[0] && !flags.store && storeIsRequired) { throw new AbortError(`Please provide a valid environment.`) } - const session = commandRequiresAuth ? await this.createSession(flags) : undefined + const shouldCreateSession = commandRequiresAuth && (storeIsRequired || flags.store) + const session = shouldCreateSession ? await this.createSession(flags) : undefined const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:single-env:authenticated`) From 4698acc386083897ffec3a63a6aa64e4ce682ce6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:12:34 +0000 Subject: [PATCH 18/60] Version Packages --- .changeset/pink-moles-leave.md | 5 ---- .changeset/short-cooks-wonder.md | 6 ---- .changeset/spicy-lemons-beam.md | 5 ---- .changeset/stale-wolves-follow.md | 5 ---- .changeset/tame-tips-play.md | 5 ---- packages/app/CHANGELOG.md | 16 +++++++++++ packages/app/package.json | 8 +++--- packages/cli-kit/CHANGELOG.md | 10 +++++++ packages/cli-kit/package.json | 2 +- packages/cli-kit/src/public/common/version.ts | 2 +- packages/cli/CHANGELOG.md | 2 ++ packages/cli/oclif.manifest.json | 2 +- packages/cli/package.json | 12 ++++---- packages/create-app/CHANGELOG.md | 2 ++ packages/create-app/oclif.manifest.json | 2 +- packages/create-app/package.json | 6 ++-- packages/plugin-cloudflare/CHANGELOG.md | 8 ++++++ packages/plugin-cloudflare/package.json | 4 +-- packages/plugin-did-you-mean/CHANGELOG.md | 8 ++++++ packages/plugin-did-you-mean/package.json | 4 +-- packages/theme/CHANGELOG.md | 9 ++++++ packages/theme/package.json | 4 +-- .../ui-extensions-dev-console/CHANGELOG.md | 7 +++++ .../ui-extensions-dev-console/package.json | 4 +-- .../ui-extensions-server-kit/CHANGELOG.md | 6 ++++ .../ui-extensions-server-kit/package.json | 2 +- pnpm-lock.yaml | 28 +++++++++---------- 27 files changed, 108 insertions(+), 66 deletions(-) delete mode 100644 .changeset/pink-moles-leave.md delete mode 100644 .changeset/short-cooks-wonder.md delete mode 100644 .changeset/spicy-lemons-beam.md delete mode 100644 .changeset/stale-wolves-follow.md delete mode 100644 .changeset/tame-tips-play.md diff --git a/.changeset/pink-moles-leave.md b/.changeset/pink-moles-leave.md deleted file mode 100644 index c6aabb802bd..00000000000 --- a/.changeset/pink-moles-leave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/theme': patch ---- - -Fix `shopify theme dev --theme-editor-sync` to avoid deleting files during race conditions, especially when multiple changes come from an external process (e.g., AI coding tools) diff --git a/.changeset/short-cooks-wonder.md b/.changeset/short-cooks-wonder.md deleted file mode 100644 index 76222c7bc21..00000000000 --- a/.changeset/short-cooks-wonder.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@shopify/cli-kit': minor -'@shopify/app': minor ---- - -Added CLI support for extensions.supported_features in toml diff --git a/.changeset/spicy-lemons-beam.md b/.changeset/spicy-lemons-beam.md deleted file mode 100644 index 0a3f70d8a3e..00000000000 --- a/.changeset/spicy-lemons-beam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/ui-extensions-server-kit': minor ---- - -Added supportedFeatures to ExtensionPayload diff --git a/.changeset/stale-wolves-follow.md b/.changeset/stale-wolves-follow.md deleted file mode 100644 index 791e5e37109..00000000000 --- a/.changeset/stale-wolves-follow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': minor ---- - -Enable custom headers in CLI GraphiQL. Users can now set custom headers like `Shopify-Search-Query-Debug=1` in the GraphiQL interface to pass debugging headers to the Admin API. diff --git a/.changeset/tame-tips-play.md b/.changeset/tame-tips-play.md deleted file mode 100644 index d97f02b9de7..00000000000 --- a/.changeset/tame-tips-play.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/cli-kit': patch ---- - -Throw descriptive AbortErrors during expected authorization errors diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 6d688453b15..e75ce10fc2c 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,21 @@ # @shopify/app +## 3.90.0 + +### Minor Changes + +- f903c47: Added CLI support for extensions.supported_features in toml +- 82b1c33: Enable custom headers in CLI GraphiQL. Users can now set custom headers like `Shopify-Search-Query-Debug=1` in the GraphiQL interface to pass debugging headers to the Admin API. + +### Patch Changes + +- Updated dependencies [d935cfb] +- Updated dependencies [f903c47] +- Updated dependencies [f9cf001] + - @shopify/theme@3.90.0 + - @shopify/cli-kit@3.90.0 + - @shopify/plugin-cloudflare@3.90.0 + ## 3.89.0 ### Minor Changes diff --git a/packages/app/package.json b/packages/app/package.json index 060d7058678..81111e3e882 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/app", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "description": "Utilities for loading, building, and publishing apps.", "homepage": "https://github.com/shopify/cli#readme", @@ -50,12 +50,12 @@ "@graphql-typed-document-node/core": "3.2.0", "@luckycatfactory/esbuild-graphql-loader": "3.8.1", "@oclif/core": "4.5.3", - "@shopify/cli-kit": "3.89.0", + "@shopify/cli-kit": "3.90.0", "@shopify/function-runner": "4.1.1", - "@shopify/plugin-cloudflare": "3.89.0", + "@shopify/plugin-cloudflare": "3.90.0", "@shopify/polaris": "12.27.0", "@shopify/polaris-icons": "8.11.1", - "@shopify/theme": "3.89.0", + "@shopify/theme": "3.90.0", "@shopify/theme-check-node": "3.23.0", "@shopify/toml-patch": "0.3.0", "body-parser": "1.20.3", diff --git a/packages/cli-kit/CHANGELOG.md b/packages/cli-kit/CHANGELOG.md index 823736708f8..a88804c0313 100644 --- a/packages/cli-kit/CHANGELOG.md +++ b/packages/cli-kit/CHANGELOG.md @@ -1,5 +1,15 @@ # @shopify/cli-kit +## 3.90.0 + +### Minor Changes + +- f903c47: Added CLI support for extensions.supported_features in toml + +### Patch Changes + +- f9cf001: Throw descriptive AbortErrors during expected authorization errors + ## 3.89.0 ## 3.88.0 diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 262c0cfeeb9..f6cd1dff889 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/cli-kit", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": false, "description": "A set of utilities, interfaces, and models that are common across all the platform features", diff --git a/packages/cli-kit/src/public/common/version.ts b/packages/cli-kit/src/public/common/version.ts index 78d97a0bf2b..165f85429f0 100644 --- a/packages/cli-kit/src/public/common/version.ts +++ b/packages/cli-kit/src/public/common/version.ts @@ -1 +1 @@ -export const CLI_KIT_VERSION = '3.89.0' +export const CLI_KIT_VERSION = '3.90.0' diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index db29c96496e..4c67ae67d0c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @shopify/cli +## 3.90.0 + ## 3.89.0 ## 3.88.0 diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e1df719bc35..e9eeceff6d9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7815,5 +7815,5 @@ "summary": "Trigger delivery of a sample webhook topic payload to a designated address." } }, - "version": "3.89.0" + "version": "3.90.0" } \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f008a03dd0..ea747a2549d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/cli", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": false, "description": "A CLI tool to build for the Shopify platform", @@ -109,11 +109,11 @@ "@oclif/core": "4.5.3", "@oclif/plugin-commands": "4.1.33", "@oclif/plugin-plugins": "5.4.47", - "@shopify/app": "3.89.0", - "@shopify/cli-kit": "3.89.0", - "@shopify/plugin-cloudflare": "3.89.0", - "@shopify/plugin-did-you-mean": "3.89.0", - "@shopify/theme": "3.89.0", + "@shopify/app": "3.90.0", + "@shopify/cli-kit": "3.90.0", + "@shopify/plugin-cloudflare": "3.90.0", + "@shopify/plugin-did-you-mean": "3.90.0", + "@shopify/theme": "3.90.0", "@shopify/cli-hydrogen": "11.1.5", "@types/global-agent": "3.0.0", "@typescript-eslint/eslint-plugin": "7.13.1", diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 7a58d03092a..37a065de393 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,7 @@ # @shopify/create-app +## 3.90.0 + ## 3.89.0 ## 3.88.0 diff --git a/packages/create-app/oclif.manifest.json b/packages/create-app/oclif.manifest.json index ceb0fbc8ac9..ada9c55975c 100644 --- a/packages/create-app/oclif.manifest.json +++ b/packages/create-app/oclif.manifest.json @@ -104,5 +104,5 @@ "summary": "Create a new app project" } }, - "version": "3.89.0" + "version": "3.90.0" } \ No newline at end of file diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 7e1ce76a532..80611edbafa 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/create-app", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": false, "description": "A CLI tool to create a new Shopify app.", @@ -58,8 +58,8 @@ "esbuild": "0.25.12" }, "devDependencies": { - "@shopify/cli-kit": "3.89.0", - "@shopify/app": "3.89.0", + "@shopify/cli-kit": "3.90.0", + "@shopify/app": "3.90.0", "esbuild-plugin-copy": "^2.1.1", "@vitest/coverage-istanbul": "^3.1.4" }, diff --git a/packages/plugin-cloudflare/CHANGELOG.md b/packages/plugin-cloudflare/CHANGELOG.md index cc698bdf262..20b555a3631 100644 --- a/packages/plugin-cloudflare/CHANGELOG.md +++ b/packages/plugin-cloudflare/CHANGELOG.md @@ -1,5 +1,13 @@ # @shopify/plugin-cloudflare +## 3.90.0 + +### Patch Changes + +- Updated dependencies [f903c47] +- Updated dependencies [f9cf001] + - @shopify/cli-kit@3.90.0 + ## 3.89.0 ### Patch Changes diff --git a/packages/plugin-cloudflare/package.json b/packages/plugin-cloudflare/package.json index 62cf8b6dc56..a42e00bd4c5 100644 --- a/packages/plugin-cloudflare/package.json +++ b/packages/plugin-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/plugin-cloudflare", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "description": "Enables the creation of Cloudflare tunnels from `shopify app dev`, allowing previews from any device", "keywords": [ @@ -47,7 +47,7 @@ }, "dependencies": { "@oclif/core": "4.5.3", - "@shopify/cli-kit": "3.89.0" + "@shopify/cli-kit": "3.90.0" }, "devDependencies": { "@vitest/coverage-istanbul": "^3.1.4" diff --git a/packages/plugin-did-you-mean/CHANGELOG.md b/packages/plugin-did-you-mean/CHANGELOG.md index 711a16aa5b8..29c4422e451 100644 --- a/packages/plugin-did-you-mean/CHANGELOG.md +++ b/packages/plugin-did-you-mean/CHANGELOG.md @@ -1,5 +1,13 @@ # @shopify/plugin-did-you-mean +## 3.90.0 + +### Patch Changes + +- Updated dependencies [f903c47] +- Updated dependencies [f9cf001] + - @shopify/cli-kit@3.90.0 + ## 3.89.0 ### Patch Changes diff --git a/packages/plugin-did-you-mean/package.json b/packages/plugin-did-you-mean/package.json index f4538efb0fd..17991f3d9ab 100644 --- a/packages/plugin-did-you-mean/package.json +++ b/packages/plugin-did-you-mean/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/plugin-did-you-mean", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": true, "bugs": { @@ -42,7 +42,7 @@ }, "dependencies": { "@oclif/core": "4.5.3", - "@shopify/cli-kit": "3.89.0", + "@shopify/cli-kit": "3.90.0", "n-gram": "2.0.2" }, "devDependencies": { diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 71d9dfda7d9..6f4aba3addc 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,14 @@ # @shopify/theme +## 3.90.0 + +### Patch Changes + +- d935cfb: Fix `shopify theme dev --theme-editor-sync` to avoid deleting files during race conditions, especially when multiple changes come from an external process (e.g., AI coding tools) +- Updated dependencies [f903c47] +- Updated dependencies [f9cf001] + - @shopify/cli-kit@3.90.0 + ## 3.89.0 ### Patch Changes diff --git a/packages/theme/package.json b/packages/theme/package.json index fa57fcde5d6..36188c6f0ce 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/theme", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": true, "description": "Utilities for building and publishing themes", @@ -42,7 +42,7 @@ }, "dependencies": { "@oclif/core": "4.5.3", - "@shopify/cli-kit": "3.89.0", + "@shopify/cli-kit": "3.90.0", "@shopify/theme-check-node": "3.23.0", "@shopify/theme-language-server-node": "2.20.0", "chokidar": "3.6.0", diff --git a/packages/ui-extensions-dev-console/CHANGELOG.md b/packages/ui-extensions-dev-console/CHANGELOG.md index eacd82e697f..0d4b96c51b4 100644 --- a/packages/ui-extensions-dev-console/CHANGELOG.md +++ b/packages/ui-extensions-dev-console/CHANGELOG.md @@ -1,5 +1,12 @@ # @shopify/ui-extensions-dev-console-app +## 3.90.0 + +### Patch Changes + +- Updated dependencies [7680a16] + - @shopify/ui-extensions-server-kit@5.4.0 + ## 3.89.0 ## 3.88.0 diff --git a/packages/ui-extensions-dev-console/package.json b/packages/ui-extensions-dev-console/package.json index 83e767d8cc9..e515aa93b27 100644 --- a/packages/ui-extensions-dev-console/package.json +++ b/packages/ui-extensions-dev-console/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/ui-extensions-dev-console-app", - "version": "3.89.0", + "version": "3.90.0", "packageManager": "pnpm@10.11.1", "private": true, "scripts": { @@ -42,7 +42,7 @@ "dependencies": { "@shopify/polaris-icons": "^8.0.0", "@shopify/react-i18n": "^6.1.0", - "@shopify/ui-extensions-server-kit": "5.3.1", + "@shopify/ui-extensions-server-kit": "5.4.0", "copy-to-clipboard": "^3.3.3", "qrcode.react": "^1.0.1", "react": "^17.0.2", diff --git a/packages/ui-extensions-server-kit/CHANGELOG.md b/packages/ui-extensions-server-kit/CHANGELOG.md index cd053826cee..5188433db83 100644 --- a/packages/ui-extensions-server-kit/CHANGELOG.md +++ b/packages/ui-extensions-server-kit/CHANGELOG.md @@ -1,5 +1,11 @@ # @shopify/ui-extensions-server-kit +## 5.4.0 + +### Minor Changes + +- 7680a16: Added supportedFeatures to ExtensionPayload + ## 5.3.1 ### Patch Changes diff --git a/packages/ui-extensions-server-kit/package.json b/packages/ui-extensions-server-kit/package.json index cb62b7b0ad9..4dcf661dac8 100644 --- a/packages/ui-extensions-server-kit/package.json +++ b/packages/ui-extensions-server-kit/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/ui-extensions-server-kit", - "version": "5.3.1", + "version": "5.4.0", "packageManager": "pnpm@10.11.1", "private": false, "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8f008d13e7..5b7795d1510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,13 +160,13 @@ importers: specifier: 4.5.3 version: 4.5.3 '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit '@shopify/function-runner': specifier: 4.1.1 version: 4.1.1 '@shopify/plugin-cloudflare': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../plugin-cloudflare '@shopify/polaris': specifier: 12.27.0 @@ -175,7 +175,7 @@ importers: specifier: 8.11.1 version: 8.11.1(react@18.3.1) '@shopify/theme': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../theme '@shopify/theme-check-node': specifier: 3.23.0 @@ -288,22 +288,22 @@ importers: specifier: 5.4.47 version: 5.4.47 '@shopify/app': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../app '@shopify/cli-hydrogen': specifier: 11.1.5 version: 11.1.5(@graphql-codegen/cli@5.0.4(@parcel/watcher@2.5.1)(@types/node@24.7.0)(crossws@0.3.5)(enquirer@2.4.1)(graphql@16.10.0)(typescript@5.8.3))(graphql-config@5.1.5(@types/node@24.7.0)(crossws@0.3.5)(graphql@16.10.0)(typescript@5.8.3))(graphql@16.10.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@6.4.1(@types/node@24.7.0)(jiti@2.4.2)(sass@1.89.1)(yaml@2.8.1)) '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit '@shopify/plugin-cloudflare': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../plugin-cloudflare '@shopify/plugin-did-you-mean': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../plugin-did-you-mean '@shopify/theme': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../theme '@types/global-agent': specifier: 3.0.0 @@ -566,10 +566,10 @@ importers: version: 0.25.12 devDependencies: '@shopify/app': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../app '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit '@vitest/coverage-istanbul': specifier: ^3.1.4 @@ -669,7 +669,7 @@ importers: specifier: 4.5.3 version: 4.5.3 '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit devDependencies: '@vitest/coverage-istanbul': @@ -682,7 +682,7 @@ importers: specifier: 4.5.3 version: 4.5.3 '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit n-gram: specifier: 2.0.2 @@ -698,7 +698,7 @@ importers: specifier: 4.5.3 version: 4.5.3 '@shopify/cli-kit': - specifier: 3.89.0 + specifier: 3.90.0 version: link:../cli-kit '@shopify/theme-check-node': specifier: 3.23.0 @@ -735,7 +735,7 @@ importers: specifier: ^6.1.0 version: 6.4.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@shopify/ui-extensions-server-kit': - specifier: 5.3.1 + specifier: 5.4.0 version: link:../ui-extensions-server-kit copy-to-clipboard: specifier: ^3.3.3 From 0c53ab159ee143b37ce0e5ec4fa69518ff4c34b1 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 26 Jan 2026 16:32:33 -0700 Subject: [PATCH 19/60] make remote dev console the default case --- .../dev-session/dev-session-process.test.ts | 25 ++++++++++--------- .../dev/processes/dev-session/dev-session.ts | 7 +++--- .../dev/processes/setup-dev-processes.test.ts | 12 ++++----- .../dev/processes/setup-dev-processes.ts | 7 +++--- .../cli-kit/src/private/node/constants.ts | 2 +- .../cli-kit/src/public/node/context/local.ts | 9 ++++--- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts index f51e4be5a72..e28fbf1daf5 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts @@ -20,7 +20,7 @@ import {AbortSignal, AbortController} from '@shopify/cli-kit/node/abort' import {flushPromises} from '@shopify/cli-kit/node/promises' import * as outputContext from '@shopify/cli-kit/node/ui/components' import {readdir} from '@shopify/cli-kit/node/fs' -import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' +import {firstPartyDev, useLocalDevConsole} from '@shopify/cli-kit/node/context/local' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/archiver') @@ -33,7 +33,7 @@ vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => { return { ...original, firstPartyDev: vi.fn().mockReturnValue(false), - skipLocalDevConsole: vi.fn().mockReturnValue(false), + useLocalDevConsole: vi.fn().mockReturnValue(false), } }) @@ -219,10 +219,10 @@ describe('pushUpdatesForDevSession', () => { contextSpy.mockRestore() }) - test('updates preview URL to appLocalProxyURL when extension is previewable (dev console shown by default)', async () => { - // Given - dev console is shown by default when skipLocalDevConsole is false + test('updates preview URL to appPreviewURL by default (local dev console is opt-in)', async () => { + // Given - dev console is NOT shown by default (requires both firstPartyDev AND useLocalDevConsole) vi.mocked(firstPartyDev).mockReturnValue(false) - vi.mocked(skipLocalDevConsole).mockReturnValue(false) + vi.mocked(useLocalDevConsole).mockReturnValue(false) const extension = await testUIExtension({type: 'ui_extension'}) const newApp = testAppLinked({allExtensions: [extension]}) @@ -234,13 +234,13 @@ describe('pushUpdatesForDevSession', () => { await flushPromises() // Then - expect(devSessionStatusManager.status.previewURL).toBe(options.appLocalProxyURL) + expect(devSessionStatusManager.status.previewURL).toBe(options.appPreviewURL) }) - test('updates preview URL to appPreviewURL when both skip conditions are met', async () => { - // Given - dev console is skipped only when !firstPartyDev() AND skipLocalDevConsole() - vi.mocked(firstPartyDev).mockReturnValue(false) - vi.mocked(skipLocalDevConsole).mockReturnValue(true) + test('updates preview URL to appLocalProxyURL when 1P dev opts in with useLocalDevConsole', async () => { + // Given - dev console is shown only when firstPartyDev() AND useLocalDevConsole() + vi.mocked(firstPartyDev).mockReturnValue(true) + vi.mocked(useLocalDevConsole).mockReturnValue(true) const extension = await testUIExtension({type: 'ui_extension'}) const newApp = testAppLinked({allExtensions: [extension]}) @@ -252,8 +252,9 @@ describe('pushUpdatesForDevSession', () => { await flushPromises() // Then - expect(devSessionStatusManager.status.previewURL).toBe(options.appPreviewURL) - vi.mocked(skipLocalDevConsole).mockReturnValue(false) + expect(devSessionStatusManager.status.previewURL).toBe(options.appLocalProxyURL) + vi.mocked(firstPartyDev).mockReturnValue(false) + vi.mocked(useLocalDevConsole).mockReturnValue(false) }) test('updates preview URL to appPreviewURL when no previewable extensions', async () => { diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index 82b3b89f802..b6d9f39e6fe 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -10,7 +10,7 @@ import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime' import {ClientError} from 'graphql-request' import {JsonMapType} from '@shopify/cli-kit/node/toml' import {AbortError} from '@shopify/cli-kit/node/error' -import {firstPartyDev, isUnitTest, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' +import {firstPartyDev, isUnitTest, useLocalDevConsole} from '@shopify/cli-kit/node/context/local' import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {readdir} from '@shopify/cli-kit/node/fs' import {SerialBatchProcessor} from '@shopify/cli-kit/node/serial-batch-processor' @@ -240,13 +240,12 @@ export class DevSession { /** * Update the preview URL, it only changes if we move between a non-previewable state and a previewable state. * (i.e. if we go from a state with no extensions to a state with ui-extensions or vice versa) - * Skip the dev console only when BOTH: SHOPIFY_CLI_1P_DEV is NOT enabled AND SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is set. + * Use local dev console only when BOTH: SHOPIFY_CLI_1P_DEV is enabled AND SHOPIFY_USE_LOCAL_DEV_CONSOLE is set. * @param event - The app event */ private updatePreviewURL(event: AppEvent) { const hasPreview = event.app.allExtensions.filter((ext) => ext.isPreviewable).length > 0 - const skipDevConsole = !firstPartyDev() && skipLocalDevConsole() - const useDevConsole = !skipDevConsole && hasPreview + const useDevConsole = firstPartyDev() && useLocalDevConsole() && hasPreview const newPreviewURL = useDevConsole ? this.options.appLocalProxyURL : this.options.appPreviewURL this.statusManager.updateStatus({previewURL: newPreviewURL}) } diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index 4b8a460a231..db3168b0ce1 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -35,7 +35,7 @@ import {Config} from '@oclif/core' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' import {isStorefrontPasswordProtected} from '@shopify/theme' import {fetchTheme} from '@shopify/cli-kit/node/themes/api' -import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' +import {firstPartyDev, useLocalDevConsole} from '@shopify/cli-kit/node/context/local' vi.mock('../../context/identifiers.js') vi.mock('@shopify/cli-kit/node/session.js') @@ -69,10 +69,10 @@ beforeEach(() => { role: 'theme', processing: false, }) - // By default, firstPartyDev is false (dev console URL only used when enabled) + // By default, firstPartyDev is false vi.mocked(firstPartyDev).mockReturnValue(false) - // By default, skipLocalDevConsole is false - vi.mocked(skipLocalDevConsole).mockReturnValue(false) + // By default, useLocalDevConsole is false (local dev console is opt-in for 1P devs) + vi.mocked(useLocalDevConsole).mockReturnValue(false) }) const appContextResult = { @@ -168,8 +168,8 @@ describe('setup-dev-processes', () => { graphiqlKey, }) - // Dev console is shown by default (only skipped when !firstPartyDev() AND skipLocalDevConsole()) - expect(res.previewUrl).toBe('https://example.com/proxy/extensions/dev-console') + // Dev console is NOT shown by default (only shown when firstPartyDev() AND useLocalDevConsole()) + expect(res.previewUrl).toBe('https://store.myshopify.io/admin/oauth/redirect_from_cli?client_id=api-key') expect(res.processes[0]).toMatchObject({ type: 'web', prefix: 'web-backend-frontend', 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 81cf8500626..545c490f723 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 @@ -22,7 +22,7 @@ import {AppEventWatcher} from '../app-events/app-event-watcher.js' import {reloadApp} from '../../../models/app/loader.js' import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' import {isTruthy} from '@shopify/cli-kit/node/context/utilities' -import {firstPartyDev, skipLocalDevConsole} from '@shopify/cli-kit/node/context/local' +import {firstPartyDev, useLocalDevConsole} from '@shopify/cli-kit/node/context/local' import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment' import {outputInfo} from '@shopify/cli-kit/node/output' @@ -100,11 +100,10 @@ export async function setupDevProcesses({ const appWatcher = new AppEventWatcher(reloadedApp, network.proxyUrl) // Decide on the appropriate preview URL for a session with these processes - // Skip the dev console only when BOTH: SHOPIFY_CLI_1P_DEV is NOT enabled AND SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is set + // Use local dev console only when BOTH: SHOPIFY_CLI_1P_DEV is enabled AND SHOPIFY_USE_LOCAL_DEV_CONSOLE is set const anyPreviewableExtensions = reloadedApp.allExtensions.some((ext) => ext.isPreviewable) const devConsoleURL = `${network.proxyUrl}/extensions/dev-console` - const skipDevConsole = !firstPartyDev() && skipLocalDevConsole() - const useDevConsole = !skipDevConsole && anyPreviewableExtensions + const useDevConsole = firstPartyDev() && useLocalDevConsole() && anyPreviewableExtensions const previewURL = useDevConsole ? devConsoleURL : appPreviewUrl const graphiqlURL = shouldRenderGraphiQL diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index 6dbe539f8f3..6b070fbf730 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -19,7 +19,7 @@ export const environmentVariables = { enableCliRedirect: 'SHOPIFY_CLI_ENABLE_CLI_REDIRECT', env: 'SHOPIFY_CLI_ENV', firstPartyDev: 'SHOPIFY_CLI_1P_DEV', - skipLocalDevConsole: 'SHOPIFY_SKIP_LOCAL_DEV_CONSOLE', + useLocalDevConsole: 'SHOPIFY_USE_LOCAL_DEV_CONSOLE', noAnalytics: 'SHOPIFY_CLI_NO_ANALYTICS', partnersToken: 'SHOPIFY_CLI_PARTNERS_TOKEN', runAsUser: 'SHOPIFY_RUN_AS_USER', diff --git a/packages/cli-kit/src/public/node/context/local.ts b/packages/cli-kit/src/public/node/context/local.ts index fde5bc4e490..7a5ab7d6847 100644 --- a/packages/cli-kit/src/public/node/context/local.ts +++ b/packages/cli-kit/src/public/node/context/local.ts @@ -113,13 +113,14 @@ export function firstPartyDev(env = process.env): boolean { } /** - * Returns true if the local dev console should be skipped. + * Returns true if the local dev console should be used. + * This is an opt-in flag for 1P developers to use the local dev console. * * @param env - The environment variables from the environment of the current process. - * @returns True if SHOPIFY_SKIP_LOCAL_DEV_CONSOLE is truthy. + * @returns True if SHOPIFY_USE_LOCAL_DEV_CONSOLE is truthy. */ -export function skipLocalDevConsole(env = process.env): boolean { - return isTruthy(env[environmentVariables.skipLocalDevConsole]) +export function useLocalDevConsole(env = process.env): boolean { + return isTruthy(env[environmentVariables.useLocalDevConsole]) } /** From 5e13c8384c647bb1f5b893317ccd581f710ce277 Mon Sep 17 00:00:00 2001 From: Chris Berthe Date: Mon, 23 Jun 2025 10:41:16 -0400 Subject: [PATCH 20/60] Add listing flag to dev, push, and share commands --- .changeset/polite-eyes-warn.md | 7 + .../interfaces/theme-dev.interface.ts | 8 +- .../interfaces/theme-push.interface.ts | 6 + .../interfaces/theme-share.interface.ts | 6 + .../generated/generated_docs_data.json | 29 +++- .../cli-kit/src/public/node/themes/types.ts | 3 +- packages/cli/README.md | 15 +- packages/cli/oclif.manifest.json | 32 ++++ packages/theme/package.json | 1 + packages/theme/src/cli/commands/theme/dev.ts | 6 + packages/theme/src/cli/commands/theme/push.ts | 5 + .../theme/src/cli/commands/theme/share.ts | 6 + packages/theme/src/cli/services/dev.ts | 4 +- packages/theme/src/cli/services/push.test.ts | 22 ++- packages/theme/src/cli/services/push.ts | 10 +- .../theme/src/cli/utilities/theme-fs.test.ts | 69 ++++++++ packages/theme/src/cli/utilities/theme-fs.ts | 43 ++++- .../src/cli/utilities/theme-listing.test.ts | 154 ++++++++++++++++++ .../theme/src/cli/utilities/theme-listing.ts | 42 +++++ pnpm-lock.yaml | 3 + 20 files changed, 459 insertions(+), 12 deletions(-) create mode 100644 .changeset/polite-eyes-warn.md create mode 100644 packages/theme/src/cli/utilities/theme-listing.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-listing.ts diff --git a/.changeset/polite-eyes-warn.md b/.changeset/polite-eyes-warn.md new file mode 100644 index 00000000000..b088f15d4bb --- /dev/null +++ b/.changeset/polite-eyes-warn.md @@ -0,0 +1,7 @@ +--- +'@shopify/cli-kit': patch +'@shopify/theme': patch +'@shopify/cli': patch +--- + +Add --listing flag to theme dev, push, and share commands diff --git a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts index 573e9e1883f..2d3233a8c39 100644 --- a/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-dev.interface.ts @@ -16,7 +16,7 @@ export interface themedev { * Controls the visibility of the error overlay when an theme asset upload fails: - silent Prevents the error overlay from appearing. - default Displays the error overlay. - + * @environment SHOPIFY_FLAG_ERROR_OVERLAY */ '--error-overlay '?: string @@ -33,6 +33,12 @@ export interface themedev { */ '-x, --ignore '?: string + /** + * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory. + * @environment SHOPIFY_FLAG_LISTING + */ + '--listing '?: string + /** * The live reload mode switches the server behavior when a file is modified: - hot-reload Hot reloads local changes to CSS and sections (default) diff --git a/docs-shopify.dev/commands/interfaces/theme-push.interface.ts b/docs-shopify.dev/commands/interfaces/theme-push.interface.ts index 53b4770400b..c6adff4c968 100644 --- a/docs-shopify.dev/commands/interfaces/theme-push.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-push.interface.ts @@ -30,6 +30,12 @@ export interface themepush { */ '-j, --json'?: '' + /** + * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory. + * @environment SHOPIFY_FLAG_LISTING + */ + '--listing '?: string + /** * Push theme files from your remote live theme. * @environment SHOPIFY_FLAG_LIVE diff --git a/docs-shopify.dev/commands/interfaces/theme-share.interface.ts b/docs-shopify.dev/commands/interfaces/theme-share.interface.ts index f08f085d46d..5e12cfbe30e 100644 --- a/docs-shopify.dev/commands/interfaces/theme-share.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-share.interface.ts @@ -6,6 +6,12 @@ export interface themeshare { */ '-e, --environment '?: string + /** + * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory. + * @environment SHOPIFY_FLAG_LISTING + */ + '--listing '?: string + /** * 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 eb04908faa7..eb2ecf40bae 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5501,6 +5501,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_HOST" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--listing ", + "value": "string", + "description": "The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_LISTING" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -6866,6 +6875,15 @@ "name": "themepush", "description": "", "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-push.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--listing ", + "value": "string", + "description": "The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_LISTING" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-push.interface.ts", "syntaxKind": "PropertySignature", @@ -7185,6 +7203,15 @@ "name": "themeshare", "description": "", "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-share.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--listing ", + "value": "string", + "description": "The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_LISTING" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-share.interface.ts", "syntaxKind": "PropertySignature", @@ -7292,4 +7319,4 @@ "category": "general commands", "related": [] } -] \ No newline at end of file +] diff --git a/packages/cli-kit/src/public/node/themes/types.ts b/packages/cli-kit/src/public/node/themes/types.ts index 39021516d8c..55f4a2ffefe 100644 --- a/packages/cli-kit/src/public/node/themes/types.ts +++ b/packages/cli-kit/src/public/node/themes/types.ts @@ -27,8 +27,9 @@ export type ThemeFSEventPayload = (ThemeFSEv export interface ThemeFileSystemOptions { filters?: {ignore?: string[]; only?: string[]} - notify?: string + listing?: string noDelete?: boolean + notify?: string } /** diff --git a/packages/cli/README.md b/packages/cli/README.md index 0b10237df25..417647ce542 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1908,8 +1908,8 @@ Uploads the current theme as a development theme to the connected store, then pr ``` USAGE $ shopify theme dev [-a] [-e ...] [--error-overlay silent|default] [--host ] [-x ...] - [--live-reload hot-reload|full-page|off] [--no-color] [-n] [--notify ] [-o ...] [--open] [--password - ] [--path ] [--port ] [-s ] [--store-password ] [-t ] + [--listing ] [--live-reload hot-reload|full-page|off] [--no-color] [-n] [--notify ] [-o ...] + [--open] [--password ] [--path ] [--port ] [-s ] [--store-password ] [-t ] [--theme-editor-sync] [--verbose] FLAGS @@ -1946,6 +1946,9 @@ FLAGS --host= Set which network interface the web server listens on. The default value is 127.0.0.1. + --listing= + The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory. + --live-reload=