From 4a6fa2f05fd212053b6bd31c1af126e73066bc25 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 16 Apr 2026 12:41:35 +0300 Subject: [PATCH 1/2] Extract @shopify/store package from @shopify/cli Move existing store:auth and store:execute commands and their services into a dedicated @shopify/store package, following the same pattern as @shopify/theme. This prepares the store namespace for new commands like store create dev. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 3 +++ packages/cli/tsconfig.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e6e74fbeb13..6a3dfdd2d9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -106,6 +106,9 @@ "store": { "description": "Work directly with Shopify stores." }, + "store:create": { + "description": "Create Shopify stores." + }, "app:config": { "description": "Manage app configuration." }, diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index bf1343e50c8..abd46c6062b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,6 +9,7 @@ }, "references": [ {"path": "../cli-kit"}, - {"path": "../plugin-did-you-mean"} + {"path": "../plugin-did-you-mean"}, + {"path": "../store"} ] } From ee242f35bdfeff2b380c0bce61b120b71274edb6 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 16 Apr 2026 18:32:28 +0300 Subject: [PATCH 2/2] Extract @shopify/organizations package with org listing and selection utilities Creates a standalone package for organization fetching and selection, calling the Business Platform Destinations API directly. Includes GraphQL codegen pipeline, fetchOrganizations (with GID decoding), selectOrganizationPrompt (auto-select for single org, duplicate name disambiguation), and selectOrg helper. Wires packages/app to use @shopify/organizations for org fetching in AppManagementClient.organizations() and for the org selection prompt, eliminating duplicated Destinations API and prompt logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/config.json | 1 + bin/get-graphql-schemas.js | 5 +- configurations/vite.config.ts | 2 + graphql.config.ts | 1 + package.json | 17 + packages/app/package.json | 1 + packages/app/src/cli/models/organization.ts | 5 +- packages/app/src/cli/prompts/dev.test.ts | 80 -- packages/app/src/cli/prompts/dev.ts | 19 - .../services/app/config/link-service.test.ts | 7 +- .../app/src/cli/services/app/env/show.test.ts | 4 +- packages/app/src/cli/services/context.test.ts | 3 +- packages/app/src/cli/services/context.ts | 2 +- packages/app/src/cli/services/info.test.ts | 4 +- .../app-management-client.test.ts | 59 +- .../app-management-client.ts | 9 +- packages/app/tsconfig.json | 2 +- packages/organizations/package.json | 62 ++ packages/organizations/project.json | 79 ++ .../destinations_schema.graphql | 691 ++++++++++++++++++ .../generated/organizations.ts | 0 .../generated/types.d.ts | 24 + .../queries/organizations.graphql | 0 .../src/cli/models/organization.ts | 4 + .../src/cli/prompts/organization.test.ts | 88 +++ .../src/cli/prompts/organization.ts | 24 + .../src/cli/services/fetch.test.ts | 82 +++ .../organizations/src/cli/services/fetch.ts | 41 ++ .../src/cli/services/select.test.ts | 55 ++ .../organizations/src/cli/services/select.ts | 25 + packages/organizations/src/index.ts | 4 + packages/organizations/tsconfig.build.json | 7 + packages/organizations/tsconfig.json | 13 + packages/organizations/vite.config.ts | 3 + pnpm-lock.yaml | 16 + vite.config.ts | 2 + 36 files changed, 1272 insertions(+), 169 deletions(-) create mode 100644 packages/organizations/package.json create mode 100644 packages/organizations/project.json create mode 100644 packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql rename packages/{app => organizations}/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts (100%) create mode 100644 packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts rename packages/{app => organizations}/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql (100%) create mode 100644 packages/organizations/src/cli/models/organization.ts create mode 100644 packages/organizations/src/cli/prompts/organization.test.ts create mode 100644 packages/organizations/src/cli/prompts/organization.ts create mode 100644 packages/organizations/src/cli/services/fetch.test.ts create mode 100644 packages/organizations/src/cli/services/fetch.ts create mode 100644 packages/organizations/src/cli/services/select.test.ts create mode 100644 packages/organizations/src/cli/services/select.ts create mode 100644 packages/organizations/src/index.ts create mode 100644 packages/organizations/tsconfig.build.json create mode 100644 packages/organizations/tsconfig.json create mode 100644 packages/organizations/vite.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index 423721a609e..b843da70cb5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,6 +7,7 @@ "@shopify/cli", "@shopify/app", "@shopify/store", + "@shopify/organizations", "@shopify/create-app", "@shopify/cli-kit", "@shopify/theme", diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index 04232c3d77b..b5a076dbd94 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -48,7 +48,10 @@ const schemas = [ owner: 'shop', repo: 'world', pathToFile: 'areas/platforms/organizations/db/graphql/destinations_schema.graphql', - localPaths: ['./packages/app/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql'], + localPaths: [ + './packages/app/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql', + './packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql', + ], }, { owner: 'shop', diff --git a/configurations/vite.config.ts b/configurations/vite.config.ts index c6faa473af7..80f8d38a2f6 100644 --- a/configurations/vite.config.ts +++ b/configurations/vite.config.ts @@ -82,5 +82,7 @@ export const aliases = (packagePath: string) => { }, }, {find: '@shopify/theme', replacement: path.join(packagePath, '../theme/src/index')}, + {find: '@shopify/organizations', replacement: path.join(packagePath, '../organizations/src/index')}, + {find: '@shopify/store', replacement: path.join(packagePath, '../store/src/index')}, ] } diff --git a/graphql.config.ts b/graphql.config.ts index 175f82c7585..bc1e9bf2229 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -85,5 +85,6 @@ export default { webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), adminAsApp: projectFactory('admin', 'admin_schema.graphql'), + organizationsDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'organizations'), }, } diff --git a/package.json b/package.json index d8d82599c2b..381e12038c2 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,23 @@ ] } }, + "packages/organizations": { + "entry": [ + "**/index.ts!" + ], + "project": "**/*.ts!", + "ignore": [ + "**/graphql/**/generated/*.ts" + ], + "ignoreDependencies": [ + "@graphql-typed-document-node/core" + ], + "vite": { + "config": [ + "vite.config.ts" + ] + } + }, "packages/cli": { "entry": [ "**/{commands,hooks}/**/*.ts!", diff --git a/packages/app/package.json b/packages/app/package.json index db14a4ebb6a..9e4c118808e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,6 +51,7 @@ "@luckycatfactory/esbuild-graphql-loader": "3.8.1", "@oclif/core": "4.5.3", "@shopify/cli-kit": "3.93.0", + "@shopify/organizations": "3.93.0", "@shopify/plugin-cloudflare": "3.93.0", "@shopify/polaris": "12.27.0", "@shopify/polaris-icons": "8.11.1", diff --git a/packages/app/src/cli/models/organization.ts b/packages/app/src/cli/models/organization.ts index c723b62bc82..0ad5058bf34 100644 --- a/packages/app/src/cli/models/organization.ts +++ b/packages/app/src/cli/models/organization.ts @@ -1,14 +1,13 @@ import {AppConfigurationUsedByCli} from './extensions/specifications/types/app_config.js' import {Flag, DeveloperPlatformClient} from '../utilities/developer-platform-client.js' +import {Organization as BaseOrganization} from '@shopify/organizations' export enum OrganizationSource { Partners = 'Partners', BusinessPlatform = 'BusinessPlatform', } -export interface Organization { - id: string - businessName: string +export interface Organization extends BaseOrganization { source: OrganizationSource } diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index c20a1b36181..6d45b5e9dae 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -3,7 +3,6 @@ import { createAsNewAppPrompt, reloadStoreListPrompt, selectAppPrompt, - selectOrganizationPrompt, selectStorePrompt, updateURLsPrompt, } from './dev.js' @@ -68,85 +67,6 @@ beforeEach(() => { vi.mocked(getTomls).mockResolvedValue({}) }) -describe('selectOrganization', () => { - test('request org selection if passing more than 1 org', async () => { - // Given - vi.mocked(renderAutocompletePrompt).mockResolvedValue('1') - - // When - const got = await selectOrganizationPrompt([ORG1, ORG2]) - - // Then - expect(got).toEqual(ORG1) - expect(renderAutocompletePrompt).toHaveBeenCalledWith({ - message: 'Which organization is this work for?', - choices: [ - {label: 'org1', value: '1'}, - {label: 'org2', value: '2'}, - ], - }) - }) - - test('returns directly if passing only 1 org', async () => { - // Given - const orgs = [ORG2] - - // When - const got = await selectOrganizationPrompt(orgs) - - // Then - expect(got).toEqual(ORG2) - expect(renderAutocompletePrompt).not.toBeCalled() - }) - - // Intentional: when ANY duplicates exist, ALL orgs get ID suffix for consistent formatting - test('appends ID to label when duplicate names exist', async () => { - // Given - const orgsWithDuplicates = [ - {id: '1', businessName: 'My Org', source: OrganizationSource.BusinessPlatform}, - {id: '2', businessName: 'My Org', source: OrganizationSource.BusinessPlatform}, - {id: '3', businessName: 'Other Org', source: OrganizationSource.BusinessPlatform}, - ] - vi.mocked(renderAutocompletePrompt).mockResolvedValue('1') - - // When - await selectOrganizationPrompt(orgsWithDuplicates) - - // Then - note: Other Org also gets ID suffix for consistency - expect(renderAutocompletePrompt).toHaveBeenCalledWith({ - message: 'Which organization is this work for?', - choices: [ - {label: 'My Org (1)', value: '1'}, - {label: 'My Org (2)', value: '2'}, - {label: 'Other Org (3)', value: '3'}, - ], - }) - }) - - test('appends ID to all labels when all names are identical', async () => { - // Given - const orgsAllSameName = [ - {id: '1', businessName: 'Same Org', source: OrganizationSource.BusinessPlatform}, - {id: '2', businessName: 'Same Org', source: OrganizationSource.BusinessPlatform}, - {id: '3', businessName: 'Same Org', source: OrganizationSource.BusinessPlatform}, - ] - vi.mocked(renderAutocompletePrompt).mockResolvedValue('2') - - // When - await selectOrganizationPrompt(orgsAllSameName) - - // Then - expect(renderAutocompletePrompt).toHaveBeenCalledWith({ - message: 'Which organization is this work for?', - choices: [ - {label: 'Same Org (1)', value: '1'}, - {label: 'Same Org (2)', value: '2'}, - {label: 'Same Org (3)', value: '3'}, - ], - }) - }) -}) - describe('selectApp', () => { test('returns app if user selects one', async () => { // Given diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 8071191364c..dcdbb7ec2c6 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -12,25 +12,6 @@ import { } from '@shopify/cli-kit/node/ui' import {outputCompleted} from '@shopify/cli-kit/node/output' -export async function selectOrganizationPrompt(organizations: Organization[]): Promise { - if (organizations.length === 1) { - return organizations[0]! - } - - // Add ID suffix to disambiguate when duplicate names exist - const uniqueNames = new Set(organizations.map((org) => org.businessName)) - const hasDuplicates = uniqueNames.size < organizations.length - const orgList = organizations.map((org) => ({ - label: hasDuplicates ? `${org.businessName} (${org.id})` : org.businessName, - value: org.id, - })) - const id = await renderAutocompletePrompt({ - message: `Which organization is this work for?`, - choices: orgList, - }) - return organizations.find((org) => org.id === id)! -} - export async function selectAppPrompt( onSearchForAppsByName: (term: string) => Promise<{apps: MinimalOrganizationApp[]; hasMorePages: boolean}>, apps: MinimalOrganizationApp[], diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index 6e76f9f5807..124ee465d0f 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -2,14 +2,16 @@ import link from './link.js' import {testOrganizationApp, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' import {DeveloperPlatformClient, selectDeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' import {OrganizationApp, OrganizationSource} from '../../../models/organization.js' -import {appNamePrompt, createAsNewAppPrompt, selectOrganizationPrompt} from '../../../prompts/dev.js' +import {appNamePrompt, createAsNewAppPrompt} from '../../../prompts/dev.js' import {selectConfigName} from '../../../prompts/config.js' +import {selectOrganizationPrompt} from '@shopify/organizations' import {beforeEach, describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' vi.mock('./use.js') vi.mock('../../../prompts/dev.js') +vi.mock('@shopify/organizations') vi.mock('../../../prompts/config.js') vi.mock('../../local-storage') vi.mock('@shopify/cli-kit/node/ui') @@ -84,7 +86,6 @@ api_version = "2024-01" vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', - source: OrganizationSource.BusinessPlatform, }) vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') @@ -178,7 +179,6 @@ api_version = "2025-07" vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', - source: OrganizationSource.BusinessPlatform, }) const options = { @@ -227,7 +227,6 @@ required = true vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', - source: OrganizationSource.BusinessPlatform, }) const options = { diff --git a/packages/app/src/cli/services/app/env/show.test.ts b/packages/app/src/cli/services/app/env/show.test.ts index 7a0a8e3b2f2..cd3e039e7c9 100644 --- a/packages/app/src/cli/services/app/env/show.test.ts +++ b/packages/app/src/cli/services/app/env/show.test.ts @@ -1,15 +1,15 @@ import {showEnv} from './show.js' import {fetchOrganizations} from '../../dev/fetch.js' import {AppInterface} from '../../../models/app/app.js' -import {selectOrganizationPrompt} from '../../../prompts/dev.js' import {testApp, testOrganizationApp} from '../../../models/app/app.test-data.js' import {OrganizationSource} from '../../../models/organization.js' +import {selectOrganizationPrompt} from '@shopify/organizations' import {describe, expect, vi, test} from 'vitest' import * as file from '@shopify/cli-kit/node/fs' import {stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' vi.mock('../../dev/fetch.js') -vi.mock('../../../prompts/dev.js') +vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/node-package-manager') describe('env show', () => { diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index c1c4b257701..9e5b1589e4b 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -16,7 +16,6 @@ import { OrganizationStore, } from '../models/organization.js' import {getAppIdentifiers} from '../models/app/identifiers.js' -import {selectOrganizationPrompt} from '../prompts/dev.js' import { testDeveloperPlatformClient, testAppWithConfig, @@ -34,6 +33,7 @@ import { selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' +import {selectOrganizationPrompt} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' @@ -118,6 +118,7 @@ vi.mock('./dev/create-extension') vi.mock('./dev/select-app') vi.mock('./dev/select-store') vi.mock('../prompts/dev') +vi.mock('@shopify/organizations') vi.mock('../models/app/identifiers') vi.mock('./context/identifiers') vi.mock('../models/app/loader.js') diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index dce2c07c7ec..8b43e2a0eea 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -5,7 +5,6 @@ import {createExtension} from './dev/create-extension.js' import {CachedAppInfo} from './local-storage.js' import {DeployOptions} from './deploy.js' import {formatConfigInfoBody} from './format-config-info-body.js' -import {selectOrganizationPrompt} from '../prompts/dev.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' import {Identifiers, updateAppIdentifiers, getAppIdentifiers} from '../models/app/identifiers.js' import {Organization, OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js' @@ -24,6 +23,7 @@ import { DeveloperPlatformClient, selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' +import {selectOrganizationPrompt} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {tryParseInt} from '@shopify/cli-kit/common/string' diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 5a34f9a79cc..2b7acb04be0 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -1,7 +1,6 @@ import {InfoOptions, info} from './info.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' import {OrganizationApp, OrganizationSource} from '../models/organization.js' -import {selectOrganizationPrompt} from '../prompts/dev.js' import { testDeveloperPlatformClient, testOrganizationApp, @@ -12,13 +11,14 @@ import { } from '../models/app/app.test-data.js' import {AppErrors} from '../models/app/loader.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' +import {selectOrganizationPrompt} from '@shopify/organizations' import {describe, expect, vi, test} from 'vitest' import {joinPath} from '@shopify/cli-kit/node/path' import {OutputMessage, TokenizedString, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' import {inTemporaryDirectory, writeFileSync} from '@shopify/cli-kit/node/fs' import {AlertCustomSection, InlineToken} from '@shopify/cli-kit/node/ui' -vi.mock('../prompts/dev.js') +vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/node-package-manager') vi.mock('../utilities/developer-platform-client.js') 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 a9c636c4b0f..008bd93d140 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 @@ -32,7 +32,7 @@ import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/ap import {MinimalAppIdentifiers} from '../../models/organization.js' import {CreateAssetUrl} from '../../api/graphql/app-management/generated/create-asset-url.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' -import {ListOrganizations} from '../../api/graphql/business-platform-destinations/generated/organizations.js' +import {fetchOrganizations} from '@shopify/organizations' import {describe, expect, test, vi, beforeEach} from 'vitest' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' @@ -49,6 +49,7 @@ import {webhooksRequestDoc} from '@shopify/cli-kit/node/api/webhooks' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/business-platform') vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/api/webhooks') beforeEach(() => { @@ -1859,45 +1860,14 @@ describe('appExtensionRegistrations', () => { }) describe('organizations', () => { - test('returns empty array when currentUserAccount is null', async () => { + test('returns organizations with source added', async () => { // Given const client = AppManagementClient.getInstance() - client.businessPlatformToken = () => Promise.resolve('business-platform-token') - - vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({ - currentUserAccount: null, - }) - - // When - const result = await client.organizations() - - // Then - expect(result).toEqual([]) - expect(businessPlatformRequestDoc).toHaveBeenCalledWith( - expect.objectContaining({ - query: ListOrganizations, - token: 'business-platform-token', - }), - ) - }) - - test('returns organizations with unique names', async () => { - // Given - const client = AppManagementClient.getInstance() - client.businessPlatformToken = () => Promise.resolve('business-platform-token') - const mockResponse = { - currentUserAccount: { - uuid: 'user-123', - organizationsWithAccessToDestination: { - nodes: [ - {id: 'Z2lkOi8vQnVzaW5lc3NQbGF0Zm9ybS9Pcmdhbml6YXRpb24vMQ==', name: 'Org One'}, - {id: 'Z2lkOi8vQnVzaW5lc3NQbGF0Zm9ybS9Pcmdhbml6YXRpb24vMg==', name: 'Org Two'}, - {id: 'Z2lkOi8vQnVzaW5lc3NQbGF0Zm9ybS9Pcmdhbml6YXRpb24vMw==', name: 'Org Three'}, - ], - }, - }, - } - vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce(mockResponse) + vi.mocked(fetchOrganizations).mockResolvedValueOnce([ + {id: '1', businessName: 'Org One'}, + {id: '2', businessName: 'Org Two'}, + {id: '3', businessName: 'Org Three'}, + ]) // When const result = await client.organizations() @@ -1910,19 +1880,10 @@ describe('organizations', () => { ]) }) - test('returns empty array when organizationsWithAccessToDestination is empty', async () => { + test('returns empty array when fetchOrganizations returns empty', async () => { // Given const client = AppManagementClient.getInstance() - client.businessPlatformToken = () => Promise.resolve('business-platform-token') - const mockResponse = { - currentUserAccount: { - uuid: 'user-123', - organizationsWithAccessToDestination: { - nodes: [], - }, - }, - } - vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce(mockResponse) + vi.mocked(fetchOrganizations).mockResolvedValueOnce([]) // When const result = await client.organizations() 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 89e4cdc7a9c..a9f53002107 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 @@ -78,7 +78,6 @@ import { ExtensionUpdateDraftMutation, ExtensionUpdateDraftMutationVariables, } from '../../api/graphql/partners/generated/update-draft.js' -import {ListOrganizations} from '../../api/graphql/business-platform-destinations/generated/organizations.js' import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_config_app_home.js' import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js' import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js' @@ -141,6 +140,7 @@ import { AppLogsSubscribeMutationVariables, } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' +import {fetchOrganizations} from '@shopify/organizations' import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' import {isUnitTest} from '@shopify/cli-kit/node/context/local' @@ -374,12 +374,9 @@ export class AppManagementClient implements DeveloperPlatformClient { } async organizations(): Promise { - const organizationsResult = await this.businessPlatformRequest({query: ListOrganizations}) - if (!organizationsResult.currentUserAccount) return [] - const orgs = organizationsResult.currentUserAccount.organizationsWithAccessToDestination.nodes + const orgs = await fetchOrganizations() return orgs.map((org) => ({ - id: idFromEncodedGid(org.id), - businessName: org.name, + ...org, source: this.organizationSource, })) } diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 0f666a7411f..65b0195ab48 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -8,5 +8,5 @@ "rootDir": "src", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, - "references": [{"path": "../cli-kit"}, {"path": "../theme"}] + "references": [{"path": "../cli-kit"}, {"path": "../organizations"}, {"path": "../theme"}] } diff --git a/packages/organizations/package.json b/packages/organizations/package.json new file mode 100644 index 00000000000..b14d7f98a2f --- /dev/null +++ b/packages/organizations/package.json @@ -0,0 +1,62 @@ +{ + "name": "@shopify/organizations", + "version": "3.93.0", + "packageManager": "pnpm@10.11.1", + "private": true, + "description": "Utilities for working with Shopify organizations", + "homepage": "https://github.com/shopify/cli#readme", + "bugs": { + "url": "https://community.shopify.dev/c/shopify-cli-libraries/14" + }, + "repository": { + "type": "git", + "url": "https://github.com/Shopify/cli.git", + "directory": "packages/organizations" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "/dist", + "/oclif.manifest.json" + ], + "scripts": { + "build": "nx build", + "clean": "nx clean", + "lint": "nx lint", + "lint:fix": "nx lint:fix", + "prepack": "NODE_ENV=production pnpm nx build && cp ../../README.md README.md", + "vitest": "vitest", + "type-check": "nx type-check" + }, + "eslintConfig": { + "extends": [ + "../../.eslintrc.cjs" + ] + }, + "dependencies": { + "@shopify/cli-kit": "3.93.0", + "@graphql-typed-document-node/core": "3.2.0" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.1.4" + }, + "engines": { + "node": ">=20.10.0" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "publishConfig": { + "@shopify:registry": "https://registry.npmjs.org", + "access": "public" + }, + "engine-strict": true +} diff --git a/packages/organizations/project.json b/packages/organizations/project.json new file mode 100644 index 00000000000..c054bf2c5d1 --- /dev/null +++ b/packages/organizations/project.json @@ -0,0 +1,79 @@ +{ + "name": "organizations", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/organizations/src", + "projectType": "library", + "tags": ["scope:feature"], + "targets": { + "clean": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm rimraf dist/", + "cwd": "packages/organizations" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist"], + "inputs": ["{projectRoot}/src/**/*", "{projectRoot}/package.json"], + "options": { + "command": "pnpm tsc -b ./tsconfig.build.json", + "cwd": "packages/organizations" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint \"src/**/*.ts\"", + "cwd": "packages/organizations" + } + }, + "lint:fix": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm eslint 'src/**/*.ts' --fix", + "cwd": "packages/organizations" + } + }, + "type-check": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm tsc --noEmit", + "cwd": "packages/organizations" + } + }, + "graphql-codegen": { + "executor": "nx:noop", + "dependsOn": ["graphql-codegen:formatting"] + }, + "graphql-codegen:formatting": { + "executor": "nx:run-commands", + "dependsOn": ["graphql-codegen:postfix"], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "options": { + "commands": ["pnpm eslint 'src/cli/api/graphql/business-platform-destinations/generated/**/*.{ts,tsx}' --fix"], + "cwd": "packages/organizations" + } + }, + "graphql-codegen:postfix": { + "executor": "nx:run-commands", + "dependsOn": ["graphql-codegen:generate:organizations-destinations"], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "options": { + "commands": [ + "find ./packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + ], + "cwd": "{workspaceRoot}" + } + }, + "graphql-codegen:generate:organizations-destinations": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/business-platform-destinations/**/*.graphql"], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=organizationsDestinations"], + "cwd": "{workspaceRoot}" + } + } + } +} diff --git a/packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql b/packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql new file mode 100644 index 00000000000..4adda2ab68a --- /dev/null +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql @@ -0,0 +1,691 @@ +enum AccountStatus { + """ + Account is active on the destination + """ + ACTIVE + + """ + Account is pending on the destination + """ + PENDING + + """ + Account is suspended on the destination + """ + SUSPENDED +} + +type Category { + """ + Destinations in this category. + """ + destinations( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): DestinationConnection! + + """ + The category name. + """ + name: String! +} + +enum CategoryHandle { + """ + Administration category such as: Apps, Billing, Staff, ... + """ + ADMINISTRATION + + """ + Sections category. Other sections in an organization. + """ + SECTIONS + + """ + Stores category such as a Shopify store. + """ + STORES + + """ + Tools category such as: Flow, Customer support, ... + """ + TOOLS +} + +type Destination { + """ + Account's status on the destination. + """ + accountStatus: AccountStatus + + """ + Child destinations associated with this destination. + """ + children( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + search: String + status: DestinationStatus + ): DestinationConnection + + """ + Destination handle + """ + handle: String + + """ + For organization destinations, whether it has a developer dashboard link. + """ + hasDevDashboardLink: Boolean! + + """ + For organization destinations, whether it has a partner dashboard link. + """ + hasPartnerDashboardLink: Boolean! + + """ + Destination Icon. + """ + icon: Icon + + """ + Destination global identifier. + """ + id: DestinationID! + + """ + Shops destination is an app development store. + """ + isAppDevelopment: Boolean + + """ + Destination is able to be deactivated by the user. + """ + isDeactivatable: Boolean + + """ + Destination last access timestamp. + """ + lastAccess: ISO8601DateTime + + """ + Destination name. + """ + name: String! + + """ + For shop destinations, the name of the parent organization. Null for non-shop destinations. + """ + parentDestinationName: String + + """ + Primary domain of the destination. + """ + primaryDomain: String + + """ + Destination public identifier. + """ + publicId: DestinationPublicID! + + """ + Shops destination short name. + """ + shortName: String + + """ + Destination status. + """ + status: DestinationStatus! + + """ + Destination type. + """ + type: DestinationType! + + """ + Web destination address. + """ + webUrl: String! +} + +""" +The connection type for Destination. +""" +type DestinationConnection { + """ + A list of edges. + """ + edges: [DestinationEdge!]! + + """ + A list of nodes. + """ + nodes: [Destination!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type DestinationEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Destination! +} + +scalar DestinationID + +scalar DestinationPublicID + +enum DestinationStatus { + ACTIVE + INACTIVE +} + +enum DestinationType { + """ + App destination. + """ + APP + + """ + Organization destination. + """ + ORGANIZATION + + """ + Shop destination. + """ + SHOP +} + +""" +Result payload for fetching an optional checkout identity token. +""" +type FetchOptionalCheckoutIdentityTokenResult { + """ + The checkout identity token if retrieval was successful. + """ + identityToken: String +} + +scalar GlobalID + +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime @specifiedBy(url: "https://tools.ietf.org/html/rfc3339") + +type Icon { + """ + Icon text. + """ + altText: String + + """ + Icon asset name. + """ + asset: String + + """ + Icon url. + """ + url: String +} + +type Mutation { + """ + Fetches a checkout identity token for optional billing integrations. + """ + fetchOptionalCheckoutIdentityToken: FetchOptionalCheckoutIdentityTokenResult! + + """ + Updates the timestamp of most recently accessed store or organization + """ + updateStoreAccessTimestamp(updateDestinationAccessTimeInput: UpdateDestinationAccessTimeInput!): Boolean! +} + +interface Node { + """ + The ID for an object. + """ + id: GlobalID! + queryComplexity: Int! + queryDepth: Int! +} + +type Organization implements Node { + """ + Whether or not the user can unlock new plus tools. + """ + canUnlockPlusTools: Boolean! + + """ + Destination categories. + """ + categories( + """ + Destination category set. + """ + handles: [CategoryHandle!] = [STORES, TOOLS, ADMINISTRATION, SECTIONS] + ): [Category!] + + """ + Returns true/false values indicating if each flag is enabled for the organization. + """ + enabledFlags( + """ + The hashed handles of the flags to check. + """ + flagHandles: [String!]! + ): [Boolean!]! + + """ + The ID for an object. + """ + id: GlobalID! + + """ + Organization name. + """ + name: String! + queryComplexity: Int! + queryDepth: Int! + + """ + Returns number of shops in the organization. + """ + shopCount: Int + + """ + Organization status. + """ + status: OrganizationStatus! + + """ + Denotes if this is a test Organization + """ + testOrganization: Boolean! + + """ + Organization address. + """ + url: String! +} + +""" +The connection type for Organization. +""" +type OrganizationConnection { + """ + A list of edges. + """ + edges: [OrganizationEdge!]! + + """ + A list of nodes. + """ + nodes: [Organization!]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type OrganizationEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Organization! +} + +""" +The ID for a Organization. +""" +scalar OrganizationID + +enum OrganizationStatus { + """ + Organization is active. + """ + ACTIVE + + """ + Organization is locked. + """ + LOCKED +} + +type OrphanDestination { + """ + Destination categories. + """ + categories( + """ + Destination category set. + """ + handles: [CategoryHandle!] = [STORES] + ): [Category!] +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String +} + +type Query { + """ + Returns the current user account. + """ + currentUserAccount( + """ + The identity UUID of the account. + """ + uuid: ID + ): UserAccount + + """ + Returns a specific account by identity uuid. + """ + loadTesting( + """ + The identity UUID of the account. + """ + uuid: ID! + ): UserAccount + + """ + Returns a specific user account by identity uuid. + """ + userAccount( + """ + The identity UUID of the account. + """ + uuid: ID! + ): UserAccount +} + +enum RestrictedDestinations { + APPS_CLI + DEVELOPER_DASHBOARD +} + +input UpdateDestinationAccessTimeInput { + """ + Destination public identifier. + """ + destinationPublicId: DestinationPublicID! +} + +type UserAccount { + """ + Specific destination associated with the account. + """ + destination(id: DestinationPublicID!): Destination + + """ + Destinations associated with an account. + """ + destinations( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Search for destinations by name and uri. + """ + search: String + + """ + Only return flat shop destinations. + """ + shopsOnly: Boolean + + """ + Filter destinations by status. If not provided, defaults to active. + """ + status: DestinationStatus = ACTIVE + ): DestinationConnection! + + """ + User account's email address. + """ + email: String! + + """ + Returns true/false values indicating if each flag is enabled for the current user. + """ + enabledUserFlags( + """ + The hashed handles of the flags to check. + """ + flagHandles: [String!]! + ): [Boolean!]! + + """ + Whether the user has at least one active store. + """ + hasActiveStores: Boolean! + + """ + Whether the user has at least one store. + """ + hasAnyStores: Boolean! + + """ + Whether the user has inactive stores. + """ + hasInactiveStores: Boolean! + + """ + The last destination the user accessed. + """ + lastAccessedDestination: Destination + + """ + Specific group associated with the account. + """ + organization( + """ + The remote ID of the organization to look up. + """ + id: OrganizationID! + ): Organization + + """ + Group associated with the destination. + """ + organizationForDestination( + """ + The public ID of the destination to look up the organization for. + """ + destinationPublicId: DestinationPublicID! + ): Organization + + """ + Organizations associated with an account. + """ + organizations( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + hasAccessToDestination: RestrictedDestinations + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): OrganizationConnection! + + """ + Organizations associated with an account for which the user has store create permission. + """ + organizationsForStoreCreationInSignup( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): OrganizationConnection! + + """ + Organizations associated with an account for which the user has access to the destination. + """ + organizationsWithAccessToDestination( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + destination: RestrictedDestinations! + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): OrganizationConnection! + + """ + Destinations that do not belong to any organization. + """ + orphanDestinations: OrphanDestination! + + """ + Returns the most recently accessed stores for the signup screen, mixing active and inactive. + """ + recentStoresForSignup( + """ + Maximum number of stores to return (1-10, default 4). + """ + limit: Int = 4 + ): [Destination!]! + + """ + User account's identity UUID. + """ + uuid: ID! +} diff --git a/packages/app/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts similarity index 100% rename from packages/app/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts rename to packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts diff --git a/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts new file mode 100644 index 00000000000..9fed0e6e078 --- /dev/null +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +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] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + DestinationID: { input: any; output: any; } + DestinationPublicID: { input: any; output: any; } + GlobalID: { input: string; output: string; } + /** An ISO 8601-encoded datetime */ + ISO8601DateTime: { input: any; output: any; } + /** The ID for a Organization. */ + OrganizationID: { input: any; output: any; } +}; diff --git a/packages/app/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql b/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql similarity index 100% rename from packages/app/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql rename to packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql diff --git a/packages/organizations/src/cli/models/organization.ts b/packages/organizations/src/cli/models/organization.ts new file mode 100644 index 00000000000..b4efab626c5 --- /dev/null +++ b/packages/organizations/src/cli/models/organization.ts @@ -0,0 +1,4 @@ +export interface Organization { + id: string + businessName: string +} diff --git a/packages/organizations/src/cli/prompts/organization.test.ts b/packages/organizations/src/cli/prompts/organization.test.ts new file mode 100644 index 00000000000..9f5dc1ddfe2 --- /dev/null +++ b/packages/organizations/src/cli/prompts/organization.test.ts @@ -0,0 +1,88 @@ +import {selectOrganizationPrompt} from './organization.js' +import {describe, expect, test, vi} from 'vitest' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' + +vi.mock('@shopify/cli-kit/node/ui') + +describe('selectOrganizationPrompt', () => { + test('auto-selects when there is only one organization', async () => { + const org = {id: '1234', businessName: 'My Org'} + + const result = await selectOrganizationPrompt([org]) + + expect(result).toEqual(org) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + }) + + test('prompts user when there are multiple organizations', async () => { + const orgs = [ + {id: '1234', businessName: 'My Org'}, + {id: '5678', businessName: 'Other Org'}, + ] + vi.mocked(renderAutocompletePrompt).mockResolvedValue('5678') + + const result = await selectOrganizationPrompt(orgs) + + expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which organization do you want to use?', + choices: [ + {label: 'My Org', value: '1234'}, + {label: 'Other Org', value: '5678'}, + ], + }) + }) + + // Intentional: when ANY duplicates exist, ALL orgs get ID suffix for consistent formatting + test('appends ID to label when duplicate names exist', async () => { + const orgs = [ + {id: '1234', businessName: 'My Org'}, + {id: '5678', businessName: 'My Org'}, + {id: '9012', businessName: 'Other Org'}, + ] + vi.mocked(renderAutocompletePrompt).mockResolvedValue('1234') + + const result = await selectOrganizationPrompt(orgs) + + expect(result).toEqual({id: '1234', businessName: 'My Org'}) + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which organization do you want to use?', + choices: [ + {label: 'My Org (1234)', value: '1234'}, + {label: 'My Org (5678)', value: '5678'}, + {label: 'Other Org (9012)', value: '9012'}, + ], + }) + }) + + test('appends ID to all labels when all names are identical', async () => { + const orgs = [ + {id: '1234', businessName: 'Same Org'}, + {id: '5678', businessName: 'Same Org'}, + ] + vi.mocked(renderAutocompletePrompt).mockResolvedValue('5678') + + const result = await selectOrganizationPrompt(orgs) + + expect(result).toEqual({id: '5678', businessName: 'Same Org'}) + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which organization do you want to use?', + choices: [ + {label: 'Same Org (1234)', value: '1234'}, + {label: 'Same Org (5678)', value: '5678'}, + ], + }) + }) + + test('preserves extra properties on the returned organization', async () => { + const orgs = [ + {id: '1234', businessName: 'My Org', source: 'BusinessPlatform'}, + {id: '5678', businessName: 'Other Org', source: 'BusinessPlatform'}, + ] + vi.mocked(renderAutocompletePrompt).mockResolvedValue('5678') + + const result = await selectOrganizationPrompt(orgs) + + expect(result).toEqual({id: '5678', businessName: 'Other Org', source: 'BusinessPlatform'}) + }) +}) diff --git a/packages/organizations/src/cli/prompts/organization.ts b/packages/organizations/src/cli/prompts/organization.ts new file mode 100644 index 00000000000..9f0a90679e4 --- /dev/null +++ b/packages/organizations/src/cli/prompts/organization.ts @@ -0,0 +1,24 @@ +import {Organization} from '../models/organization.js' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' + +export async function selectOrganizationPrompt( + organizations: T[], +): Promise { + if (organizations.length === 1) { + return organizations[0]! + } + + // Add ID suffix to disambiguate when duplicate names exist + const uniqueNames = new Set(organizations.map((org) => org.businessName)) + const hasDuplicates = uniqueNames.size < organizations.length + + const selectedId = await renderAutocompletePrompt({ + message: 'Which organization do you want to use?', + choices: organizations.map((org) => ({ + label: hasDuplicates ? `${org.businessName} (${org.id})` : org.businessName, + value: org.id, + })), + }) + + return organizations.find((org) => org.id === selectedId)! +} diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts new file mode 100644 index 00000000000..0cd16e8f13b --- /dev/null +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -0,0 +1,82 @@ +import {fetchOrganizations} from './fetch.js' +import {describe, expect, test, vi} from 'vitest' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') + +const ENCODED_GID_1 = Buffer.from('gid://organization/Organization/1234').toString('base64') +const ENCODED_GID_2 = Buffer.from('gid://organization/Organization/5678').toString('base64') + +describe('fetchOrganizations', () => { + test('returns organizations with decoded numeric IDs', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + organizationsWithAccessToDestination: { + nodes: [ + {id: ENCODED_GID_1, name: 'My Org'}, + {id: ENCODED_GID_2, name: 'Other Org'}, + ], + }, + }, + }) + + const orgs = await fetchOrganizations() + + expect(orgs).toEqual([ + {id: '1234', businessName: 'My Org'}, + {id: '5678', businessName: 'Other Org'}, + ]) + }) + + test('returns empty array when no currentUserAccount is returned', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: null, + }) + + const orgs = await fetchOrganizations() + expect(orgs).toEqual([]) + }) + + test('returns empty array when organizations list is empty', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + organizationsWithAccessToDestination: { + nodes: [], + }, + }, + }) + + const orgs = await fetchOrganizations() + expect(orgs).toEqual([]) + }) + + test('passes token and unauthorized handler to businessPlatformRequestDoc', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + organizationsWithAccessToDestination: { + nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + }, + }, + }) + + await fetchOrganizations() + + expect(businessPlatformRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'test-token', + unauthorizedHandler: expect.objectContaining({ + type: 'token_refresh', + }), + }), + ) + }) +}) diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts new file mode 100644 index 00000000000..0d35fb8f899 --- /dev/null +++ b/packages/organizations/src/cli/services/fetch.ts @@ -0,0 +1,41 @@ +import {ListOrganizations} from '../api/graphql/business-platform-destinations/generated/organizations.js' +import {Organization} from '../models/organization.js' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' + +export async function fetchOrganizations(): Promise { + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + const result = await businessPlatformRequestDoc({ + query: ListOrganizations, + token, + unauthorizedHandler, + }) + + if (!result.currentUserAccount) { + return [] + } + + const orgs = result.currentUserAccount.organizationsWithAccessToDestination.nodes + return orgs.map((org) => ({ + id: idFromEncodedGid(org.id), + businessName: org.name, + })) +} + +function idFromEncodedGid(gid: string): string { + const decodedGid = Buffer.from(gid, 'base64').toString('ascii') + const match = decodedGid.match(/\/(\d+)$/) + if (!match) { + throw new AbortError(`Failed to decode organization ID from: ${gid}`) + } + return match[1]! +} diff --git a/packages/organizations/src/cli/services/select.test.ts b/packages/organizations/src/cli/services/select.test.ts new file mode 100644 index 00000000000..dd6efdfd87c --- /dev/null +++ b/packages/organizations/src/cli/services/select.test.ts @@ -0,0 +1,55 @@ +import {selectOrg} from './select.js' +import {fetchOrganizations} from './fetch.js' +import {selectOrganizationPrompt} from '../prompts/organization.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./fetch.js') +vi.mock('../prompts/organization.js') + +const ORGS = [ + {id: '1234', businessName: 'My Org'}, + {id: '5678', businessName: 'Other Org'}, +] + +describe('selectOrg', () => { + test('returns org matching flag ID', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) + + const result = await selectOrg('5678') + + expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(selectOrganizationPrompt).not.toHaveBeenCalled() + }) + + test('throws AbortError when flag ID does not match any org', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) + + await expect(selectOrg('9999')).rejects.toThrow('Organization with ID 9999 not found.') + }) + + test('falls back to prompt when no flag is provided', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) + vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORGS[0]!) + + const result = await selectOrg() + + expect(result).toEqual({id: '1234', businessName: 'My Org'}) + expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) + }) + + test('falls back to prompt when flag is undefined', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) + vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORGS[1]!) + + const result = await selectOrg(undefined) + + expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) + }) + + test('throws AbortError when no organizations are found', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue([]) + + await expect(selectOrg()).rejects.toThrow('No organizations found.') + }) +}) diff --git a/packages/organizations/src/cli/services/select.ts b/packages/organizations/src/cli/services/select.ts new file mode 100644 index 00000000000..ad4515b8c60 --- /dev/null +++ b/packages/organizations/src/cli/services/select.ts @@ -0,0 +1,25 @@ +import {fetchOrganizations} from './fetch.js' +import {selectOrganizationPrompt} from '../prompts/organization.js' +import {Organization} from '../models/organization.js' +import {AbortError} from '@shopify/cli-kit/node/error' + +export async function selectOrg(orgIdFromFlag?: string): Promise { + const organizations = await fetchOrganizations() + + if (organizations.length === 0) { + throw new AbortError('No organizations found.', 'Make sure you have access to a Shopify organization.') + } + + if (orgIdFromFlag) { + const org = organizations.find((org) => org.id === orgIdFromFlag) + if (!org) { + throw new AbortError( + `Organization with ID ${orgIdFromFlag} not found.`, + `Available organizations: ${organizations.map((o) => `${o.businessName} (${o.id})`).join(', ')}`, + ) + } + return org + } + + return selectOrganizationPrompt(organizations) +} diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts new file mode 100644 index 00000000000..baa41bd1b32 --- /dev/null +++ b/packages/organizations/src/index.ts @@ -0,0 +1,4 @@ +export {fetchOrganizations} from './cli/services/fetch.js' +export {selectOrg} from './cli/services/select.js' +export {selectOrganizationPrompt} from './cli/prompts/organization.js' +export type {Organization} from './cli/models/organization.js' diff --git a/packages/organizations/tsconfig.build.json b/packages/organizations/tsconfig.build.json new file mode 100644 index 00000000000..16506ad61a2 --- /dev/null +++ b/packages/organizations/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts"], + "references": [ + {"path": "../cli-kit"} + ] +} diff --git a/packages/organizations/tsconfig.json b/packages/organizations/tsconfig.json new file mode 100644 index 00000000000..ea7490fa22f --- /dev/null +++ b/packages/organizations/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../configurations/tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["./dist"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "references": [ + {"path": "../cli-kit"} + ] +} diff --git a/packages/organizations/vite.config.ts b/packages/organizations/vite.config.ts new file mode 100644 index 00000000000..9536586ca45 --- /dev/null +++ b/packages/organizations/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../configurations/vite.config' + +export default config(__dirname) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b34dd994bb..110258ddca7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: '@shopify/cli-kit': specifier: 3.93.0 version: link:../cli-kit + '@shopify/organizations': + specifier: 3.93.0 + version: link:../organizations '@shopify/plugin-cloudflare': specifier: 3.93.0 version: link:../plugin-cloudflare @@ -595,6 +598,19 @@ importers: specifier: 3.8.1 version: 3.8.1 + packages/organizations: + dependencies: + '@graphql-typed-document-node/core': + specifier: 3.2.0 + version: 3.2.0(graphql@16.10.0) + '@shopify/cli-kit': + specifier: 3.93.0 + version: link:../cli-kit + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.1.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@28.1.0)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(sass@1.97.3)(yaml@2.8.3)) + packages/plugin-cloudflare: dependencies: '@oclif/core': diff --git a/vite.config.ts b/vite.config.ts index 6cf5b7a9ee8..1012152abc4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,8 +12,10 @@ export default defineConfig({ 'packages/app/vite.config.ts', 'packages/cli/vite.config.ts', 'packages/cli-kit/vite.config.ts', + 'packages/organizations/vite.config.ts', 'packages/plugin-cloudflare/vite.config.ts', 'packages/plugin-did-you-mean/vite.config.ts', + 'packages/store/vite.config.ts', 'packages/theme/vite.config.ts', 'packages/ui-extensions-dev-console/vite.config.mts', 'packages/ui-extensions-server-kit/vite.config.mts',