diff --git a/packages/app/src/cli/commands/app/build.ts b/packages/app/src/cli/commands/app/build.ts index 0cff69a8e8a..bfef69c1ed5 100644 --- a/packages/app/src/cli/commands/app/build.ts +++ b/packages/app/src/cli/commands/app/build.ts @@ -34,12 +34,12 @@ export default class Build extends AppUnlinkedCommand { cmd_app_dependency_installation_skipped: flags['skip-dependencies-installation'], })) - const app = await localAppContext({ + const {app, project} = await localAppContext({ directory: flags.path, userProvidedConfigName: flags.config, }) - await build({app, skipDependenciesInstallation: flags['skip-dependencies-installation'], apiKey: clientId}) + await build({app, project, skipDependenciesInstallation: flags['skip-dependencies-installation'], apiKey: clientId}) return {app} } diff --git a/packages/app/src/cli/commands/app/config/use.ts b/packages/app/src/cli/commands/app/config/use.ts index 7854466879c..448b565fb29 100644 --- a/packages/app/src/cli/commands/app/config/use.ts +++ b/packages/app/src/cli/commands/app/config/use.ts @@ -35,7 +35,7 @@ export default class ConfigUse extends AppUnlinkedCommand { public async run(): Promise { const {flags, args} = await this.parse(ConfigUse) - const app = await localAppContext({ + const {app} = await localAppContext({ directory: flags.path, userProvidedConfigName: args.config, }) diff --git a/packages/app/src/cli/commands/app/deploy.ts b/packages/app/src/cli/commands/app/deploy.ts index 1f7ec37618b..62f59dc70ee 100644 --- a/packages/app/src/cli/commands/app/deploy.ts +++ b/packages/app/src/cli/commands/app/deploy.ts @@ -104,7 +104,7 @@ export default class Deploy extends AppLinkedCommand { } this.failMissingNonTTYFlags(flags, requiredNonTTYFlags) - const {app, remoteApp, developerPlatformClient, organization} = await linkedAppContext({ + const {app, project, remoteApp, developerPlatformClient, organization} = await linkedAppContext({ directory: flags.path, clientId, forceRelink: flags.reset, @@ -116,6 +116,7 @@ export default class Deploy extends AppLinkedCommand { const result = await deploy({ app, + project, remoteApp, organization, developerPlatformClient, diff --git a/packages/app/src/cli/commands/app/function/build.ts b/packages/app/src/cli/commands/app/function/build.ts index 4b96ce1d8c0..7e7e821dd65 100644 --- a/packages/app/src/cli/commands/app/function/build.ts +++ b/packages/app/src/cli/commands/app/function/build.ts @@ -22,7 +22,7 @@ export default class FunctionBuild extends AppUnlinkedCommand { public async run(): Promise { const {flags} = await this.parse(FunctionBuild) - const app = await localAppContext({ + const {app} = await localAppContext({ directory: flags.path, userProvidedConfigName: flags.config, }) diff --git a/packages/app/src/cli/commands/app/function/info.ts b/packages/app/src/cli/commands/app/function/info.ts index 28b72502b13..345c0790f26 100644 --- a/packages/app/src/cli/commands/app/function/info.ts +++ b/packages/app/src/cli/commands/app/function/info.ts @@ -33,7 +33,7 @@ export default class FunctionInfo extends AppUnlinkedCommand { public async run(): Promise { const {flags} = await this.parse(FunctionInfo) - const app = await localAppContext({ + const {app} = await localAppContext({ directory: flags.path, userProvidedConfigName: flags.config, }) diff --git a/packages/app/src/cli/commands/app/function/run.ts b/packages/app/src/cli/commands/app/function/run.ts index 2e5ea046c2a..b4efc67d9d7 100644 --- a/packages/app/src/cli/commands/app/function/run.ts +++ b/packages/app/src/cli/commands/app/function/run.ts @@ -40,7 +40,7 @@ export default class FunctionRun extends AppUnlinkedCommand { let functionExport = DEFAULT_FUNCTION_EXPORT - const app = await localAppContext({ + const {app} = await localAppContext({ directory: flags.path, userProvidedConfigName: flags.config, }) diff --git a/packages/app/src/cli/commands/app/function/typegen.ts b/packages/app/src/cli/commands/app/function/typegen.ts index 9fd9a738cc4..527c5a1d1bf 100644 --- a/packages/app/src/cli/commands/app/function/typegen.ts +++ b/packages/app/src/cli/commands/app/function/typegen.ts @@ -22,7 +22,7 @@ export default class FunctionTypegen extends AppUnlinkedCommand { public async run(): Promise { const {flags} = await this.parse(FunctionTypegen) - const app = await localAppContext({ + const {app} = await localAppContext({ directory: flags.path, userProvidedConfigName: flags.config, }) diff --git a/packages/app/src/cli/commands/app/generate/extension.ts b/packages/app/src/cli/commands/app/generate/extension.ts index ffb81c0ec65..25629eec374 100644 --- a/packages/app/src/cli/commands/app/generate/extension.ts +++ b/packages/app/src/cli/commands/app/generate/extension.ts @@ -77,7 +77,7 @@ export default class AppGenerateExtension extends AppLinkedCommand { await checkFolderIsValidApp(flags.path) - const {app, specifications, remoteApp, developerPlatformClient} = await linkedAppContext({ + const {app, project, specifications, remoteApp, developerPlatformClient} = await linkedAppContext({ directory: flags.path, clientId: flags['client-id'], forceRelink: flags.reset, @@ -92,6 +92,7 @@ export default class AppGenerateExtension extends AppLinkedCommand { template: flags.template, flavor: flags.flavor, app, + project, specifications, remoteApp, developerPlatformClient, diff --git a/packages/app/src/cli/commands/app/info.ts b/packages/app/src/cli/commands/app/info.ts index c25b331400f..b629828e5d3 100644 --- a/packages/app/src/cli/commands/app/info.ts +++ b/packages/app/src/cli/commands/app/info.ts @@ -34,14 +34,14 @@ export default class AppInfo extends AppLinkedCommand { public async run(): Promise { const {flags} = await this.parse(AppInfo) - const {app, remoteApp, organization, developerPlatformClient} = await linkedAppContext({ + const {app, project, remoteApp, organization, developerPlatformClient} = await linkedAppContext({ directory: flags.path, clientId: flags['client-id'], forceRelink: flags.reset, userProvidedConfigName: flags.config, unsafeReportMode: true, }) - const results = await info(app, remoteApp, organization, { + const results = await info(app, remoteApp, organization, project, { format: (flags.json ? 'json' : 'text') as Format, webEnv: flags['web-env'], configName: flags.config, 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 2747cd6c5e7..e963d5964bb 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -74,12 +74,14 @@ import {SchemaDefinitionByTargetQueryVariables} from '../../api/graphql/function import {SchemaDefinitionByApiTypeQueryVariables} from '../../api/graphql/functions/generated/schema-definition-by-api-type.js' import {AppHomeSpecIdentifier} from '../extensions/specifications/app_config_app_home.js' import {AppProxySpecIdentifier} from '../extensions/specifications/app_config_app_proxy.js' -import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/specification.js' +import {ExtensionSpecification} from '../extensions/specification.js' import {AppLogsOptions} from '../../services/app-logs/utils.js' import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js' +import {Project} from '../project/project.js' import {Session} from '@shopify/cli-kit/node/session' import {vi} from 'vitest' import {joinPath} from '@shopify/cli-kit/node/path' +import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' export const DEFAULT_CONFIG = { application_url: 'https://myapp.com', @@ -104,9 +106,7 @@ export function testApp(app: Partial = {}): AppInterface { name: app.name ?? 'App', directory: app.directory ?? '/tmp/project', configPath: app.configPath ?? '/tmp/project/shopify.app.toml', - packageManager: app.packageManager ?? 'yarn', configuration: app.configuration ?? getConfig(), - nodeDependencies: app.nodeDependencies ?? {}, webs: app.webs ?? [ { directory: '', @@ -117,7 +117,6 @@ export function testApp(app: Partial = {}): AppInterface { }, ], modules: app.allExtensions ?? [], - usesWorkspaces: app.usesWorkspaces ?? false, dotenv: app.dotenv, errors: app.errors, specifications: app.specifications ?? [], @@ -127,9 +126,6 @@ export function testApp(app: Partial = {}): AppInterface { devApplicationURLs: app.devApplicationURLs, }) - if (app.updateDependencies) { - Object.getPrototypeOf(newApp).updateDependencies = app.updateDependencies - } if (app.extensionsForType) { Object.getPrototypeOf(newApp).extensionsForType = app.extensionsForType } @@ -155,6 +151,34 @@ export function testAppWithConfig(options?: TestAppWithConfigOptions): AppLinked return app } +interface TestProjectOptions { + directory?: string + packageManager?: PackageManager + nodeDependencies?: Record + usesWorkspaces?: boolean +} + +/** + * Creates a minimal Project mock for testing. + * Use this when a service needs a Project for packageManager, usesWorkspaces, or directory. + */ +export function testProject(options: TestProjectOptions = {}): Project { + return { + directory: options.directory ?? '/tmp/project', + packageManager: options.packageManager ?? 'yarn', + nodeDependencies: options.nodeDependencies ?? {}, + usesWorkspaces: options.usesWorkspaces ?? false, + appConfigFiles: [], + extensionConfigFiles: [], + webConfigFiles: [], + dotenvFiles: new Map(), + hiddenConfigRaw: {}, + appConfigByName: () => undefined, + appConfigByClientId: () => undefined, + defaultAppConfig: undefined, + } as unknown as Project +} + export function getWebhookConfig(webhookConfigOverrides?: WebhooksConfig): CurrentAppConfiguration { return { ...DEFAULT_CONFIG, @@ -1526,5 +1550,5 @@ export async function buildVersionedAppSchema() { } export async function configurationSpecifications() { - return (await loadLocalExtensionsSpecifications()).filter(isAppConfigSpecification) + return (await loadLocalExtensionsSpecifications()).filter((spec) => spec.uidStrategy === 'single') } diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index d5e0edfeb14..6cf75077a34 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -16,7 +16,7 @@ import {WebhookSubscription} from '../extensions/specifications/types/app_config import {joinPath} from '@shopify/cli-kit/node/path' import {ZodObjectOf, zod} from '@shopify/cli-kit/node/schema' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' -import {getDependencies, PackageManager, readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager' +import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager' import { fileExistsSync, fileRealPath, @@ -222,11 +222,8 @@ export interface AppInterface< TModuleSpec extends ExtensionSpecification = ExtensionSpecification, > extends AppConfigurationInterface { name: string - packageManager: PackageManager idEnvironmentVariableName: 'SHOPIFY_API_KEY' - nodeDependencies: {[key: string]: string} webs: Web[] - usesWorkspaces: boolean dotenv?: DotEnvFile allExtensions: ExtensionInstance[] realExtensions: ExtensionInstance[] @@ -236,7 +233,6 @@ export interface AppInterface< hiddenConfig: AppHiddenConfig includeConfigOnDeploy: boolean | undefined readonly devApplicationURLs?: ApplicationURLs - updateDependencies: () => Promise extensionsForType: (spec: {identifier: string; externalIdentifier: string}) => ExtensionInstance[] updateExtensionUUIDS: (uuids: {[key: string]: string}) => void preDeployValidation: () => Promise @@ -264,11 +260,8 @@ type AppConstructor< TModuleSpec extends ExtensionSpecification = ExtensionSpecification, > = AppConfigurationInterface & { name: string - packageManager: PackageManager - nodeDependencies: {[key: string]: string} webs: Web[] modules: ExtensionInstance[] - usesWorkspaces: boolean dotenv?: DotEnvFile errors?: AppErrors specifications: ExtensionSpecification[] @@ -285,11 +278,8 @@ export class App< idEnvironmentVariableName: 'SHOPIFY_API_KEY' = 'SHOPIFY_API_KEY' as const directory: string configPath: string - packageManager: PackageManager configuration: TConfig - nodeDependencies: {[key: string]: string} webs: Web[] - usesWorkspaces: boolean dotenv?: DotEnvFile errors?: AppErrors specifications: TModuleSpec[] @@ -303,12 +293,9 @@ export class App< name, directory, configPath, - packageManager, configuration, - nodeDependencies, webs, modules, - usesWorkspaces, dotenv, errors, specifications, @@ -320,14 +307,11 @@ export class App< this.name = name this.directory = directory this.configPath = configPath - this.packageManager = packageManager this.configuration = configuration - this.nodeDependencies = nodeDependencies this.webs = webs this.dotenv = dotenv this.realExtensions = modules this.errors = errors - this.usesWorkspaces = usesWorkspaces this.specifications = specifications this.configSchema = configSchema ?? AppSchema this.remoteFlags = remoteFlags ?? [] @@ -381,11 +365,6 @@ export class App< } } - async updateDependencies() { - const nodeDependencies = await getDependencies(joinPath(this.directory, 'package.json')) - this.nodeDependencies = nodeDependencies - } - get hiddenConfig() { return this._hiddenConfig } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index d5c3c321930..585316f9d2e 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -24,13 +24,7 @@ import {WebhooksSchema} from '../extensions/specifications/app_config_webhook_sc import {WebhooksConfig} from '../extensions/specifications/types/app_config_webhook.js' import {Flag} from '../../utilities/developer-platform-client.js' import {describe, expect, beforeEach, afterEach, beforeAll, test, vi} from 'vitest' -import { - installNodeModules, - yarnLockfile, - pnpmLockfile, - PackageJson, - pnpmWorkspaceFile, -} from '@shopify/cli-kit/node/node-package-manager' +import {installNodeModules, PackageJson} from '@shopify/cli-kit/node/node-package-manager' import {inTemporaryDirectory, moveFile, mkdir, mkTmpDir, rmdir, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath, dirname, cwd, normalizePath} from '@shopify/cli-kit/node/path' import {platformAndArch} from '@shopify/cli-kit/node/os' @@ -39,7 +33,6 @@ import {zod} from '@shopify/cli-kit/node/schema' import colors from '@shopify/cli-kit/node/colors' import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning' import {AbortError} from '@shopify/cli-kit/node/error' -import {captureOutput} from '@shopify/cli-kit/node/system' vi.mock('../../services/local-storage.js') // Mock captureOutput to prevent executing `npm prefix` inside getPackageManager @@ -354,72 +347,9 @@ describe('load', () => { expect(app.name).toBe('config-name') }) - test('defaults to npm as the package manager when the configuration is valid', async () => { - // Given - await writeConfig(appConfiguration) - vi.mocked(captureOutput).mockResolvedValue(tmpDir) - - // When - const app = await loadTestingApp() - - // Then - expect(app.packageManager).toBe('npm') - }) - - test('defaults to yarn as the package manager when yarn.lock is present, the configuration is valid, and has no blocks', async () => { - // Given - await writeConfig(appConfiguration) - const yarnLockPath = joinPath(tmpDir, yarnLockfile) - await writeFile(yarnLockPath, '') - vi.mocked(captureOutput).mockResolvedValue(tmpDir) - - // When - const app = await loadTestingApp() - - // Then - expect(app.packageManager).toBe('yarn') - }) - - test('defaults to pnpm as the package manager when pnpm lockfile is present, the configuration is valid, and has no blocks', async () => { - // Given - await writeConfig(appConfiguration) - const pnpmLockPath = joinPath(tmpDir, pnpmLockfile) - await writeFile(pnpmLockPath, '') - vi.mocked(captureOutput).mockResolvedValue(tmpDir) - - // When - const app = await loadTestingApp() - - // Then - expect(app.packageManager).toBe('pnpm') - }) - - test("identifies if the app doesn't use workspaces", async () => { - // Given - await writeConfig(appConfiguration) - - // When - const app = await loadTestingApp() - - // Then - expect(app.usesWorkspaces).toBe(false) - }) - - test('identifies if the app uses yarn or npm workspaces', async () => { - // Given - await writeConfig(appConfiguration, { - workspaces: ['packages/*'], - name: 'my_app', - dependencies: {}, - devDependencies: {}, - }) + // packageManager is now owned by Project and tested in project.test.ts - // When - const app = await loadTestingApp() - - // Then - expect(app.usesWorkspaces).toBe(true) - }) + // usesWorkspaces is now owned by Project and tested in project.test.ts test('checks for multiple CLI installations', async () => { // Given @@ -437,19 +367,6 @@ describe('load', () => { expect(showMultipleCLIWarningIfNeeded).toHaveBeenCalled() }) - test('identifies if the app uses pnpm workspaces', async () => { - // Given - await writeConfig(appConfiguration) - const pnpmWorkspaceFilePath = joinPath(tmpDir, pnpmWorkspaceFile) - await writeFile(pnpmWorkspaceFilePath, '') - - // When - const app = await loadTestingApp() - - // Then - expect(app.usesWorkspaces).toBe(true) - }) - test('does not double-count webs defined in workspaces', async () => { // Given await writeConfig(appConfiguration, { @@ -474,7 +391,6 @@ describe('load', () => { app = await loadTestingApp() // Then - expect(app.usesWorkspaces).toBe(true) expect(app.webs.length).toBe(1) }, 30000) @@ -2552,9 +2468,6 @@ describe('load', () => { }, }) expect(reloadedApp.name).toBe(app.name) - expect(reloadedApp.packageManager).toBe(app.packageManager) - expect(reloadedApp.nodeDependencies).toEqual(app.nodeDependencies) - expect(reloadedApp.usesWorkspaces).toBe(app.usesWorkspaces) }) test('call app.generateExtensionTypes', async () => { diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index ff61fdf6cd6..943a2fd6d74 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -287,8 +287,6 @@ export async function loadAppFromContext const configurationPath = activeConfig.file.path @@ -352,6 +350,7 @@ export type OpaqueAppLoadResult = state: 'loaded-app' app: AppInterface configuration: CurrentAppConfiguration + packageManager: PackageManager } | { state: 'loaded-template' @@ -393,14 +392,15 @@ export async function loadOpaqueApp(options: { }): Promise { // Try to load the app normally first try { - const app = await loadApp({ - directory: options.directory, - userProvidedConfigName: options.configName, + const {project, activeConfig} = await getAppConfigurationContext(options.directory, options.configName) + const app = await loadAppFromContext({ + project, + activeConfig, specifications: options.specifications, remoteFlags: options.remoteFlags, mode: options.mode ?? 'report', }) - return {state: 'loaded-app', app, configuration: app.configuration} + return {state: 'loaded-app', app, configuration: app.configuration, packageManager: project.packageManager} // eslint-disable-next-line no-catch-all/no-catch-all } catch { // loadApp failed - try loading as raw template config @@ -480,14 +480,11 @@ class AppLoader { if (this.specifications.length === 0) return [] - const extensionPromises = await this.createExtensionInstances(appDirectory, appConfiguration.extension_directories) + const extensionPromises = await this.createExtensionInstances(appDirectory) const configExtensionPromises = await this.createConfigExtensionInstances(appDirectory, appConfiguration) const webhookPromises = this.createWebhookSubscriptionInstances(appDirectory, appConfiguration) @@ -668,7 +662,7 @@ class AppLoader { }) }) - test('Project metadata matches the old loader metadata', async () => { + test('Project loads correct metadata from filesystem', async () => { await inTemporaryDirectory(async (dir) => { await setupRealApp(dir) - const specifications = await loadLocalExtensionsSpecifications() const project = await Project.load(dir) - const app = await loadApp({ - directory: dir, - userProvidedConfigName: undefined, - specifications, - mode: 'report', - }) - expect(project.packageManager).toBe(app.packageManager) - expect(project.nodeDependencies).toStrictEqual(app.nodeDependencies) - expect(project.usesWorkspaces).toBe(app.usesWorkspaces) + expect(project.packageManager).toBe('npm') + expect(project.nodeDependencies).toStrictEqual({}) + expect(project.usesWorkspaces).toBe(false) }) }) diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index 40b41159915..18753cb4983 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -65,10 +65,11 @@ client_id="test-api-key"` // Then expect(result).toEqual({ app: expect.objectContaining({ - configuration: { + configPath: normalizePath(joinPath(tmp, 'shopify.app.toml')), + configuration: expect.objectContaining({ client_id: 'test-api-key', name: 'test-app', - }, + }), }), remoteApp: mockRemoteApp, developerPlatformClient: expect.any(Object), @@ -282,14 +283,14 @@ describe('localAppContext', () => { // Then expect(result).toBeDefined() - expect(result.name).toEqual(expect.any(String)) - expect(result.directory).toEqual(normalizePath(tmp)) - expect(result.configuration).toEqual( + expect(result.app.name).toEqual(expect.any(String)) + expect(result.app.directory).toEqual(normalizePath(tmp)) + expect(result.app.configuration).toEqual( expect.objectContaining({ name: 'test-app', }), ) - expect(result.configPath).toEqual(normalizePath(joinPath(tmp, 'shopify.app.toml'))) + expect(result.app.configPath).toEqual(normalizePath(joinPath(tmp, 'shopify.app.toml'))) // Verify no network calls were made expect(appFromIdentifiers).not.toHaveBeenCalled() expect(fetchOrgFromId).not.toHaveBeenCalled() @@ -322,12 +323,12 @@ describe('localAppContext', () => { // Then expect(result).toBeDefined() - expect(result.configuration).toEqual( + expect(result.app.configuration).toEqual( expect.objectContaining({ name: 'test-app-custom', }), ) - expect(result.configPath).toEqual(normalizePath(joinPath(tmp, 'shopify.app.custom.toml'))) + expect(result.app.configPath).toEqual(normalizePath(joinPath(tmp, 'shopify.app.custom.toml'))) }) }) @@ -427,7 +428,7 @@ describe('localAppContext', () => { }) // Then - const realExtensions = result.allExtensions.filter((ext) => !ext.isAppConfigExtension) + const realExtensions = result.app.allExtensions.filter((ext) => ext.specification.experience !== 'configuration') expect(realExtensions).toHaveLength(1) expect(realExtensions[0]).toEqual( expect.objectContaining({ diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index bf9bc4144a3..22367657c56 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -7,7 +7,7 @@ import {addUidToTomlsIfNecessary} from './app/add-uid-to-extension-toml.js' import {loadLocalExtensionsSpecifications} from '../models/extensions/load-specifications.js' import {Organization, OrganizationApp, OrganizationSource} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' -import {getAppConfigurationContext, loadApp, loadAppFromContext} from '../models/app/loader.js' +import {getAppConfigurationContext, loadAppFromContext} from '../models/app/loader.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' import {AppLinkedInterface, AppInterface} from '../models/app/app.js' import {Project} from '../models/project/project.js' @@ -87,8 +87,9 @@ export async function linkedAppContext({ } // Determine the effective client ID - const configClientId = activeConfig.file.content.client_id as string - const effectiveClientId = clientId ?? configClientId + const configClientId = + typeof activeConfig.file.content.client_id === 'string' ? activeConfig.file.content.client_id : '' + const effectiveClientId = clientId || configClientId // Fetch the remote app, using a different clientID if provided via flag. if (!remoteApp) { @@ -146,24 +147,23 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re })) } +interface LocalAppContextOutput { + app: AppInterface + project: Project +} + /** * This function loads an app locally without making any network calls. * It uses local specifications and doesn't require the app to be linked. * - * @returns The local app instance. + * @returns The local app and project instances. */ export async function localAppContext({ directory, userProvidedConfigName, -}: LocalAppContextOptions): Promise { - // Load local specifications only +}: LocalAppContextOptions): Promise { + const {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName) const specifications = await loadLocalExtensionsSpecifications() - - // Load the local app using the specifications - return loadApp({ - directory, - userProvidedConfigName, - specifications, - mode: 'local', - }) + const app = await loadAppFromContext({project, activeConfig, specifications, mode: 'local'}) + return {app, project} } diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 83a2a57d21d..48e243bdccd 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -1859,6 +1859,7 @@ async function mockLoadOpaqueAppWithApp( state: 'loaded-app', app: mockedApp, configuration: mockedApp.configuration, + packageManager: 'yarn', }) // Also mock loadApp for backward compatibility with getAppCreationDefaultsFromLocalApp vi.mocked(loadApp).mockResolvedValue(mockedApp) diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 7d57920934a..52aaf375ebd 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -247,7 +247,7 @@ export async function loadLocalAppOptions( existingBuildOptions: configuration.build, existingConfig: {...configuration}, appDirectory: app.directory, - packageManager: app.packageManager, + packageManager: result.packageManager, } } return { diff --git a/packages/app/src/cli/services/app/env/pull.test.ts b/packages/app/src/cli/services/app/env/pull.test.ts index 2d9d18e49dd..c8fd46e2488 100644 --- a/packages/app/src/cli/services/app/env/pull.test.ts +++ b/packages/app/src/cli/services/app/env/pull.test.ts @@ -100,9 +100,7 @@ describe('env pull', () => { }) }) -function mockApp(currentVersion = '2.2.2'): AppInterface { - const nodeDependencies: {[key: string]: string} = {} - nodeDependencies['@shopify/cli'] = currentVersion +function mockApp(): AppInterface { return testApp({ name: 'myapp', directory: '/', @@ -116,6 +114,5 @@ function mockApp(currentVersion = '2.2.2'): AppInterface { }, extension_directories: ['extensions/*'], }, - nodeDependencies, }) } 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 67225c2811f..7a0a8e3b2f2 100644 --- a/packages/app/src/cli/services/app/env/show.test.ts +++ b/packages/app/src/cli/services/app/env/show.test.ts @@ -45,9 +45,7 @@ describe('env show', () => { }) }) -function mockApp(currentVersion = '2.2.2'): AppInterface { - const nodeDependencies: {[key: string]: string} = {} - nodeDependencies['@shopify/cli'] = currentVersion +function mockApp(): AppInterface { return testApp({ name: 'myapp', directory: '/', @@ -60,6 +58,5 @@ function mockApp(currentVersion = '2.2.2'): AppInterface { scopes: 'my-scope', }, }, - nodeDependencies, }) } diff --git a/packages/app/src/cli/services/build.ts b/packages/app/src/cli/services/build.ts index 2e172c78fac..e1b0e597f7a 100644 --- a/packages/app/src/cli/services/build.ts +++ b/packages/app/src/cli/services/build.ts @@ -2,19 +2,21 @@ import buildWeb from './web.js' import {installAppDependencies} from './dependencies.js' import {installJavy} from './function/build.js' import {AppInterface, Web} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import {renderConcurrent, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {Writable} from 'stream' interface BuildOptions { app: AppInterface + project: Project skipDependenciesInstallation: boolean apiKey?: string } async function build(options: BuildOptions) { - if (!options.skipDependenciesInstallation && !options.app.usesWorkspaces) { - await installAppDependencies(options.app) + if (!options.skipDependenciesInstallation && !options.project.usesWorkspaces) { + await installAppDependencies(options.project) } const env: {SHOPIFY_API_KEY?: string} = {} diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index 340d205a472..c1c4b257701 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -22,6 +22,7 @@ import { testAppWithConfig, testOrganizationApp, testThemeExtensions, + testProject, } from '../models/app/app.test-data.js' import metadata from '../metadata.js' import {getAppConfigurationFileName, isWebType, loadApp} from '../models/app/loader.js' @@ -79,6 +80,7 @@ const STORE1: OrganizationStore = { const deployOptions = (app: AppLinkedInterface, reset = false, force = false): DeployOptions => { return { app, + project: testProject(), remoteApp: APP2, organization: ORG1, reset, @@ -416,6 +418,7 @@ describe('ensureDeployContext', () => { // When const options = { app, + project: testProject(), remoteApp: APP2, organization: ORG1, reset: false, @@ -458,6 +461,7 @@ describe('ensureDeployContext', () => { // When const options = { app, + project: testProject(), remoteApp: APP2, organization: ORG1, reset: false, @@ -517,6 +521,7 @@ describe('ensureDeployContext', () => { // When const result = await ensureDeployContext({ app, + project: testProject(), remoteApp: APP2, organization: ORG1, reset: false, @@ -563,6 +568,7 @@ describe('ensureDeployContext', () => { // When const result = await ensureDeployContext({ app, + project: testProject(), remoteApp: APP2, organization: ORG1, reset: false, diff --git a/packages/app/src/cli/services/dependencies.test.ts b/packages/app/src/cli/services/dependencies.test.ts index 780ae365d14..363bbe98c25 100644 --- a/packages/app/src/cli/services/dependencies.test.ts +++ b/packages/app/src/cli/services/dependencies.test.ts @@ -1,6 +1,5 @@ import {installAppDependencies} from './dependencies.js' -import {AppInterface} from '../models/app/app.js' -import {testApp} from '../models/app/app.test-data.js' +import {testProject} from '../models/app/app.test-data.js' import {describe, expect, test, vi} from 'vitest' import {installNPMDependenciesRecursively} from '@shopify/cli-kit/node/node-package-manager' import {renderTasks} from '@shopify/cli-kit/node/ui' @@ -11,10 +10,10 @@ vi.mock('@shopify/cli-kit/node/ui') describe('installAppDependencies', () => { test('installs dependencies recursively', async () => { // Given - const app: AppInterface = testApp({updateDependencies: () => Promise.resolve()}) + const project = testProject({packageManager: 'yarn', directory: '/tmp/project'}) // When - await installAppDependencies(app) + await installAppDependencies(project) // Then expect(vi.mocked(renderTasks).mock.calls.length).toEqual(1) diff --git a/packages/app/src/cli/services/dependencies.ts b/packages/app/src/cli/services/dependencies.ts index 53253be4b37..5ef3b16f0b4 100644 --- a/packages/app/src/cli/services/dependencies.ts +++ b/packages/app/src/cli/services/dependencies.ts @@ -1,28 +1,25 @@ -import {AppInterface} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import {installNPMDependenciesRecursively} from '@shopify/cli-kit/node/node-package-manager' import {renderTasks} from '@shopify/cli-kit/node/ui' /** - * Given an app, it installs its NPM dependencies by traversing + * Given a project, it installs its NPM dependencies by traversing * the sub-directories and finding the ones that have NPM dependencies * defined in package.json files. - * @param app - App whose dependencies will be installed. - * @returns An copy of the app with the Node dependencies updated. + * @param project - Project whose dependencies will be installed. */ -export async function installAppDependencies(app: AppInterface) { +export async function installAppDependencies(project: Project) { const tasks = [ { title: 'Installing dependencies', task: async () => { await installNPMDependenciesRecursively({ - packageManager: app.packageManager, - directory: app.directory, + packageManager: project.packageManager, + directory: project.directory, deep: 3, }) }, }, ] await renderTasks(tasks) - await app.updateDependencies() - return app } diff --git a/packages/app/src/cli/services/deploy.test.ts b/packages/app/src/cli/services/deploy.test.ts index 3718bc092b6..8a695e91e60 100644 --- a/packages/app/src/cli/services/deploy.test.ts +++ b/packages/app/src/cli/services/deploy.test.ts @@ -15,6 +15,7 @@ import { testDeveloperPlatformClient, testAppLinked, testOrganization, + testProject, } from '../models/app/app.test-data.js' import {updateAppIdentifiers} from '../models/app/identifiers.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' @@ -614,7 +615,13 @@ describe('deploy', () => { nextSteps: [ [ 'Run', - {command: formatPackageManagerCommand(app.packageManager, 'shopify app release', `--version=${versionTag}`)}, + { + command: formatPackageManagerCommand( + testProject().packageManager, + 'shopify app release', + `--version=${versionTag}`, + ), + }, 'to release this version to users.', ], ], @@ -650,7 +657,7 @@ describe('deploy', () => { title: 'Next steps', body: [ '• Map extension IDs to other copies of your app by running', - {command: formatPackageManagerCommand(app.packageManager, 'shopify app deploy')}, + {command: formatPackageManagerCommand(testProject().packageManager, 'shopify app deploy')}, 'for: ', {list: {items: ['shopify.app.prod.toml', 'shopify.app.stg.toml']}}, "• Commit to source control to ensure your extension IDs aren't regenerated on the next deploy.", @@ -728,6 +735,7 @@ async function testDeployBundle({ await deploy({ app, + project: testProject(), remoteApp, organization: testOrganization(), reset: false, diff --git a/packages/app/src/cli/services/deploy.ts b/packages/app/src/cli/services/deploy.ts index 5cda1effbf9..66848fa8666 100644 --- a/packages/app/src/cli/services/deploy.ts +++ b/packages/app/src/cli/services/deploy.ts @@ -5,6 +5,7 @@ import {bundleAndBuildExtensions} from './deploy/bundle.js' import {allExtensionTypes, filterOutImportedExtensions, importAllExtensions} from './import-extensions.js' import {getExtensions} from './fetch-extensions.js' import {AppLinkedInterface} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import {updateAppIdentifiers} from '../models/app/identifiers.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {Organization, OrganizationApp} from '../models/organization.js' @@ -23,6 +24,9 @@ export interface DeployOptions { /** The app to be built and uploaded */ app: AppLinkedInterface + /** The project environment (packageManager, directory, etc.) */ + project: Project + /** The remote app to be deployed */ remoteApp: OrganizationApp @@ -277,6 +281,7 @@ export async function deploy(options: DeployOptions) { await outputCompletionMessage({ app, + project: options.project, release, uploadExtensionsBundleResult, didMigrateExtensionsToDevDash, @@ -297,11 +302,13 @@ export async function deploy(options: DeployOptions) { async function outputCompletionMessage({ app, + project, release, uploadExtensionsBundleResult, didMigrateExtensionsToDevDash, }: { app: AppLinkedInterface + project: Project release: boolean uploadExtensionsBundleResult: UploadExtensionsBundleOutput didMigrateExtensionsToDevDash: boolean @@ -320,7 +327,7 @@ async function outputCompletionMessage({ body.push( '• Map extension IDs to other copies of your app by running', { - command: formatPackageManagerCommand(app.packageManager, 'shopify app deploy'), + command: formatPackageManagerCommand(project.packageManager, 'shopify app deploy'), }, 'for: ', { @@ -372,7 +379,7 @@ async function outputCompletionMessage({ 'Run', { command: formatPackageManagerCommand( - app.packageManager, + project.packageManager, 'shopify app release', `--version=${uploadExtensionsBundleResult.versionTag}`, ), diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index 781ab61ee2d..c1a7888f892 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -1,5 +1,10 @@ import {warnIfScopesDifferBeforeDev, blockIfMigrationIncomplete} from './dev.js' -import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../models/app/app.test-data.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganizationApp, + testProject, +} from '../models/app/app.test-data.js' import {describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -33,7 +38,11 @@ describe('warnIfScopesDifferBeforeDev', () => { // When const mockOutput = mockAndCaptureOutput() mockOutput.clear() - await warnIfScopesDifferBeforeDev({...apps, developerPlatformClient}) + await warnIfScopesDifferBeforeDev({ + ...apps, + developerPlatformClient, + commandOptions: {project: testProject()} as any, + }) // Then expect(mockOutput.warn()).toBe('') @@ -47,7 +56,11 @@ describe('warnIfScopesDifferBeforeDev', () => { // When const mockOutput = mockAndCaptureOutput() mockOutput.clear() - await warnIfScopesDifferBeforeDev({...apps, developerPlatformClient}) + await warnIfScopesDifferBeforeDev({ + ...apps, + developerPlatformClient, + commandOptions: {project: testProject()} as any, + }) // Then expect(mockOutput.warn()).toContain("The scopes in your TOML don't match") @@ -61,7 +74,11 @@ describe('warnIfScopesDifferBeforeDev', () => { // When const mockOutput = mockAndCaptureOutput() mockOutput.clear() - await warnIfScopesDifferBeforeDev({...apps, developerPlatformClient}) + await warnIfScopesDifferBeforeDev({ + ...apps, + developerPlatformClient, + commandOptions: {project: testProject()} as any, + }) // Then expect(mockOutput.warn()).toBe('') diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 0b946cb0be3..620d7a86e05 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -29,6 +29,7 @@ import {TunnelMode} from './dev/tunnel-mode.js' import {PortDetail, renderPortWarnings} from './dev/port-warnings.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {Web, getAppScopesArray, AppLinkedInterface} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import {Organization, OrganizationApp, OrganizationStore} from '../models/organization.js' import {getAnalyticsTunnelType} from '../utilities/analytics.js' import metadata from '../metadata.js' @@ -52,6 +53,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' export interface DevOptions { app: AppLinkedInterface + project: Project remoteApp: OrganizationApp organization: Organization specifications: RemoteAwareExtensionSpecification[] @@ -118,8 +120,8 @@ async function prepareForDev(commandOptions: DevOptions): Promise { await configFile.patch({build: {dev_store_url: store.shopDomain}}) } - if (!commandOptions.skipDependenciesInstallation && !app.usesWorkspaces) { - await installAppDependencies(app) + if (!commandOptions.skipDependenciesInstallation && !commandOptions.project.usesWorkspaces) { + await installAppDependencies(commandOptions.project) } const graphiqlPort = commandOptions.graphiqlPort ?? (await getAvailableTCPPort(ports.graphiql)) @@ -198,7 +200,8 @@ export async function warnIfScopesDifferBeforeDev({ localApp, remoteApp, developerPlatformClient, -}: Pick) { + commandOptions, +}: Pick) { if (developerPlatformClient.supportsDevSessions) return const localAccess = localApp.configuration.access_scopes const remoteAccess = remoteApp.configuration?.access_scopes @@ -218,7 +221,7 @@ export async function warnIfScopesDifferBeforeDev({ const nextSteps = [ [ 'Run', - {command: formatPackageManagerCommand(localApp.packageManager, 'shopify app deploy')}, + {command: formatPackageManagerCommand(commandOptions.project.packageManager, 'shopify app deploy')}, 'to push your scopes to the Partner Dashboard', ], ] 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 cad404fc6c6..add3d387079 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 @@ -24,6 +24,7 @@ import { testAppLinked, testOrganization, testOrganizationStore, + testProject, } from '../../../models/app/app.test-data.js' import {WebType} from '../../../models/app/app.js' import {ensureDeploymentIdsPresence} from '../../context/identifiers.js' @@ -85,6 +86,7 @@ beforeEach(() => { const appContextResult = { app: testAppLinked(), + project: testProject(), remoteApp: testOrganizationApp(), developerPlatformClient: testDeveloperPlatformClient(), organization: testOrganization(), diff --git a/packages/app/src/cli/services/generate.test.ts b/packages/app/src/cli/services/generate.test.ts index b84a5dfd548..f9379ae3066 100644 --- a/packages/app/src/cli/services/generate.test.ts +++ b/packages/app/src/cli/services/generate.test.ts @@ -9,6 +9,7 @@ import { testOrganizationApp, testRemoteExtensionTemplates, testUIExtension, + testProject, } from '../models/app/app.test-data.js' import {ExtensionInstance} from '../models/extensions/extension-instance.js' import generateExtensionPrompts from '../prompts/generate/extension.js' @@ -60,7 +61,15 @@ describe('generate', () => { const outputInfo = await mockSuccessfulCommandExecution('subscription_ui') // When - await generate({directory: '/', reset: false, app, remoteApp, specifications, developerPlatformClient}) + await generate({ + directory: '/', + reset: false, + app, + project: testProject(), + remoteApp, + specifications, + developerPlatformClient, + }) // Then expect(outputInfo.info()).toMatchInlineSnapshot(` @@ -82,7 +91,15 @@ describe('generate', () => { const outputInfo = await mockSuccessfulCommandExecution('theme_app_extension') // When - await generate({directory: '/', reset: false, app, remoteApp, specifications, developerPlatformClient}) + await generate({ + directory: '/', + reset: false, + app, + project: testProject(), + remoteApp, + specifications, + developerPlatformClient, + }) // Then expect(outputInfo.info()).toMatchInlineSnapshot(` @@ -104,7 +121,15 @@ describe('generate', () => { const outputInfo = await mockSuccessfulCommandExecution('product_discounts') // When - await generate({directory: '/', reset: false, app, remoteApp, specifications, developerPlatformClient}) + await generate({ + directory: '/', + reset: false, + app, + project: testProject(), + remoteApp, + specifications, + developerPlatformClient, + }) // Then expect(outputInfo.info()).toMatchInlineSnapshot(` @@ -129,6 +154,7 @@ describe('generate', () => { directory: '/', reset: false, app, + project: testProject(), remoteApp, specifications, developerPlatformClient, @@ -149,6 +175,7 @@ describe('generate', () => { directory: '/', reset: false, app, + project: testProject(), remoteApp, specifications, developerPlatformClient, @@ -169,6 +196,7 @@ describe('generate', () => { directory: '/', reset: false, app, + project: testProject(), remoteApp, specifications, developerPlatformClient, @@ -188,6 +216,7 @@ describe('generate', () => { directory: '/', reset: false, app, + project: testProject(), remoteApp, specifications, developerPlatformClient, diff --git a/packages/app/src/cli/services/generate.ts b/packages/app/src/cli/services/generate.ts index 50c6cc5bfd1..e0500470739 100644 --- a/packages/app/src/cli/services/generate.ts +++ b/packages/app/src/cli/services/generate.ts @@ -7,6 +7,7 @@ import { } from './generate/extension.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import generateExtensionPrompts, { GenerateExtensionPromptOptions, GenerateExtensionPromptOutput, @@ -25,6 +26,7 @@ import {groupBy} from '@shopify/cli-kit/common/collection' interface GenerateOptions { app: AppLinkedInterface + project: Project specifications: RemoteAwareExtensionSpecification[] remoteApp: OrganizationApp developerPlatformClient: DeveloperPlatformClient @@ -54,7 +56,7 @@ async function generate(options: GenerateOptions) { const generateExtensionOptions = buildGenerateOptions(promptAnswers, app, options, developerPlatformClient) const generatedExtension = await generateExtensionTemplate(generateExtensionOptions) - renderSuccessMessage(generatedExtension, app.packageManager) + renderSuccessMessage(generatedExtension, options.project.packageManager) } async function buildPromptOptions( @@ -118,6 +120,7 @@ function buildGenerateOptions( ): GenerateExtensionTemplateOptions { return { app, + project: options.project, cloneUrl: options.cloneUrl, extensionChoices: promptAnswers.extensionContent, extensionTemplate: promptAnswers.extensionTemplate, @@ -125,7 +128,7 @@ function buildGenerateOptions( } } -function renderSuccessMessage(extension: GeneratedExtension, packageManager: AppInterface['packageManager']) { +function renderSuccessMessage(extension: GeneratedExtension, packageManager: PackageManager) { const formattedSuccessfulMessage = formatSuccessfulRunMessage( extension.extensionTemplate, extension.directory, diff --git a/packages/app/src/cli/services/generate/extension.test.ts b/packages/app/src/cli/services/generate/extension.test.ts index 7b7f7d1882b..8c97436e93d 100644 --- a/packages/app/src/cli/services/generate/extension.test.ts +++ b/packages/app/src/cli/services/generate/extension.test.ts @@ -17,6 +17,7 @@ import {ExtensionTemplate} from '../../models/app/template.js' import {ExtensionSpecification} from '../../models/extensions/specification.js' import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' import {AppLinkedInterface} from '../../models/app/app.js' +import {Project} from '../../models/project/project.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {describe, expect, vi, test} from 'vitest' import * as output from '@shopify/cli-kit/node/output' @@ -559,6 +560,7 @@ async function createFromTemplate({ onGetTemplateRepository, developerPlatformClient = testDeveloperPlatformClient(), }: CreateFromTemplateOptions): Promise { + const project = await Project.load(appDirectory) const result = await generateExtensionTemplate({ extensionTemplate: specification, app: (await loadApp({ @@ -566,6 +568,7 @@ async function createFromTemplate({ specifications, userProvidedConfigName: undefined, })) as AppLinkedInterface, + project, extensionChoices: {name, flavor: extensionFlavor}, developerPlatformClient, onGetTemplateRepository, diff --git a/packages/app/src/cli/services/generate/extension.ts b/packages/app/src/cli/services/generate/extension.ts index 3a47ee52d64..06e89ffd9ae 100644 --- a/packages/app/src/cli/services/generate/extension.ts +++ b/packages/app/src/cli/services/generate/extension.ts @@ -1,5 +1,6 @@ import {configurationFileNames, versions} from '../../constants.js' import {AppLinkedInterface} from '../../models/app/app.js' +import {Project} from '../../models/project/project.js' import {buildGraphqlTypes, PREFERRED_FUNCTION_NPM_PACKAGE_MAJOR_VERSION} from '../function/build.js' import {GenerateExtensionContentOutput} from '../../prompts/generate/extension.js' import {ExtensionFlavor, ExtensionTemplate} from '../../models/app/template.js' @@ -23,6 +24,7 @@ import {nonRandomUUID} from '@shopify/cli-kit/node/crypto' export interface GenerateExtensionTemplateOptions { app: AppLinkedInterface + project: Project cloneUrl?: string extensionChoices: GenerateExtensionContentOutput extensionTemplate: ExtensionTemplate @@ -69,6 +71,7 @@ interface ExtensionInitOptions { directory: string url: string app: AppLinkedInterface + project: Project type: string name: string extensionFlavor: ExtensionFlavor | undefined @@ -90,6 +93,7 @@ export async function generateExtensionTemplate( directory, url, app: options.app, + project: options.project, type: options.extensionTemplate.type, name: extensionName, extensionFlavor, @@ -147,6 +151,7 @@ async function functionExtensionInit({ directory, url, app, + project, name, extensionFlavor, onGetTemplateRepository, @@ -184,15 +189,15 @@ async function functionExtensionInit({ title: 'Installing additional dependencies', task: async () => { // We need to run install once to setup the workspace correctly - if (app.usesWorkspaces) { - await installNodeModules({packageManager: app.packageManager, directory: app.directory}) + if (project.usesWorkspaces) { + await installNodeModules({packageManager: project.packageManager, directory: project.directory}) } const requiredDependencies = getFunctionRuntimeDependencies(templateLanguage) await addNPMDependenciesIfNeeded(requiredDependencies, { - packageManager: app.packageManager, + packageManager: project.packageManager, type: 'prod', - directory: app.usesWorkspaces ? directory : app.directory, + directory: project.usesWorkspaces ? directory : project.directory, }) }, }) @@ -214,6 +219,7 @@ async function uiExtensionInit({ directory, url, app, + project, name, extensionFlavor, onGetTemplateRepository, @@ -250,23 +256,23 @@ async function uiExtensionInit({ { title: 'Installing dependencies', task: async () => { - const packageManager = app.packageManager - if (app.usesWorkspaces) { + const packageManager = project.packageManager + if (project.usesWorkspaces) { // Only install dependencies if the extension is javascript if (getTemplateLanguage(extensionFlavor?.value) === 'javascript') { await installNodeModules({ packageManager, - directory: app.directory, + directory: project.directory, }) } } else { - await addResolutionOrOverrideIfNeeded(app.directory, extensionFlavor?.value) + await addResolutionOrOverrideIfNeeded(project.directory, extensionFlavor?.value) const extensionPackageJsonPath = joinPath(directory, 'package.json') const requiredDependencies = await getProdDependencies(extensionPackageJsonPath) await addNPMDependenciesIfNeeded(requiredDependencies, { packageManager, type: 'prod', - directory: app.directory, + directory: project.directory, }) await removeFile(extensionPackageJsonPath) } diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 35e4758d21e..6edfc42dec8 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -8,6 +8,7 @@ import { testUIExtension, testAppConfigExtensions, testAppLinked, + testProject, } from '../models/app/app.test-data.js' import {AppErrors} from '../models/app/loader.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' @@ -87,7 +88,10 @@ describe('info', () => { vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) // When - const result = (await info(app, remoteApp, ORG1, {...infoOptions(), webEnv: true})) as OutputMessage + const result = (await info(app, remoteApp, ORG1, testProject(), { + ...infoOptions(), + webEnv: true, + })) as OutputMessage // Then expect(unstyled(stringifyMessage(result))).toMatchInlineSnapshot(` @@ -107,7 +111,7 @@ describe('info', () => { vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) // When - const result = (await info(app, remoteApp, ORG1, { + const result = (await info(app, remoteApp, ORG1, testProject(), { ...infoOptions(), format: 'json', webEnv: true, @@ -159,7 +163,7 @@ describe('info', () => { vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) // When - const result = (await info(app, remoteApp, ORG1, infoOptions())) as AlertCustomSection[] + const result = (await info(app, remoteApp, ORG1, testProject(), infoOptions())) as AlertCustomSection[] const uiData = tabularDataSectionFromInfo(result, 'ui_extension_external') const checkoutData = tabularDataSectionFromInfo(result, 'checkout_ui_extension_external') @@ -207,7 +211,7 @@ describe('info', () => { vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) // When - const result = (await info(app, remoteApp, ORG1, infoOptions())) as AlertCustomSection[] + const result = (await info(app, remoteApp, ORG1, testProject(), infoOptions())) as AlertCustomSection[] const uiExtensionsData = tabularDataSectionFromInfo(result, 'ui_extension_external') const relevantExtension = extensionTitleRow(uiExtensionsData, 'handle-for-extension-1') const irrelevantExtension = extensionTitleRow(uiExtensionsData, 'point_of_sale') @@ -241,7 +245,11 @@ describe('info', () => { vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) // When - const result = await info(app, remoteApp, ORG1, {format: 'json', webEnv: false, developerPlatformClient}) + const result = await info(app, remoteApp, ORG1, testProject(), { + format: 'json', + webEnv: false, + developerPlatformClient, + }) // Then expect(result).toBeInstanceOf(TokenizedString) @@ -249,24 +257,25 @@ describe('info', () => { const extensionsIdentifiers = resultObject.allExtensions.map((extension) => extension.localIdentifier) expect(extensionsIdentifiers).toContain('handle-for-extension-1') expect(extensionsIdentifiers).not.toContain('point_of_sale') + + // Verify backward-compat: project fields injected into JSON output + const rawResult = JSON.parse((result as TokenizedString).value) + expect(rawResult.packageManager).toBe('yarn') + expect(rawResult.nodeDependencies).toEqual({}) + expect(rawResult.usesWorkspaces).toBe(false) }) }) }) function mockApp({ directory, - currentVersion = '2.2.2', configContents = 'scopes = "read_products"', app, }: { directory: string - currentVersion?: string configContents?: string app?: Partial }): AppLinkedInterface { - const nodeDependencies: {[key: string]: string} = {} - nodeDependencies['@shopify/cli'] = currentVersion - writeFileSync(joinPath(directory, 'shopify.app.toml'), configContents) return testAppLinked({ @@ -283,7 +292,6 @@ function mockApp({ }, extension_directories: ['extensions/*'], }, - nodeDependencies, ...(app ? app : {}), }) } diff --git a/packages/app/src/cli/services/info.ts b/packages/app/src/cli/services/info.ts index 3b2718a6629..7b2e8985cba 100644 --- a/packages/app/src/cli/services/info.ts +++ b/packages/app/src/cli/services/info.ts @@ -1,6 +1,7 @@ import {outputEnv} from './app/env/show.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {AppLinkedInterface, getAppScopes} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import {configurationFileNames} from '../constants.js' import {ExtensionInstance} from '../models/extensions/extension-instance.js' import {Organization, OrganizationApp} from '../models/organization.js' @@ -30,12 +31,13 @@ export async function info( app: AppLinkedInterface, remoteApp: OrganizationApp, organization: Organization, + project: Project, options: InfoOptions, ): Promise { if (options.webEnv) { return infoWeb(app, remoteApp, organization, options) } else { - return infoApp(app, remoteApp, options) + return infoApp(app, remoteApp, project, options) } } @@ -51,12 +53,16 @@ async function infoWeb( async function infoApp( app: AppLinkedInterface, remoteApp: OrganizationApp, + project: Project, options: InfoOptions, ): Promise { if (options.format === 'json') { const extensionsInfo = withPurgedSchemas(app.allExtensions.filter((ext) => ext.isReturnedAsInfo())) let appWithSupportedExtensions = { ...app, + packageManager: project.packageManager, + nodeDependencies: project.nodeDependencies, + usesWorkspaces: project.usesWorkspaces, allExtensions: extensionsInfo, } if ('realExtensions' in appWithSupportedExtensions) { @@ -82,7 +88,7 @@ async function infoApp( 2, )}` } else { - const appInfo = new AppInfo(app, remoteApp, options) + const appInfo = new AppInfo(app, remoteApp, project, options) return appInfo.output() } } @@ -114,11 +120,13 @@ const NOT_LOADED_TEXT = 'NOT LOADED' class AppInfo { private readonly app: AppLinkedInterface private readonly remoteApp: OrganizationApp + private readonly project: Project private readonly options: InfoOptions - constructor(app: AppLinkedInterface, remoteApp: OrganizationApp, options: InfoOptions) { + constructor(app: AppLinkedInterface, remoteApp: OrganizationApp, project: Project, options: InfoOptions) { this.app = app this.remoteApp = remoteApp + this.project = project this.options = options } @@ -165,7 +173,7 @@ class AppInfo { { body: [ '💡 To change these, run', - {command: formatPackageManagerCommand(this.app.packageManager, 'shopify app config link')}, + {command: formatPackageManagerCommand(this.project.packageManager, 'shopify app config link')}, ], }, ] @@ -266,7 +274,7 @@ class AppInfo { const {platform, arch} = platformAndArch() return this.tableSection('Tooling and System', [ ['Shopify CLI', CLI_KIT_VERSION], - ['Package manager', this.app.packageManager], + ['Package manager', this.project.packageManager], ['OS', `${platform}-${arch}`], ['Shell', process.env.SHELL ?? 'unknown'], ['Node version', process.version],