From 1f2004f176369d0a93b1b1ee5f7ba37ad2215caf Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Tue, 17 Mar 2026 21:51:16 -0600 Subject: [PATCH 01/10] Remove packageManager, nodeDependencies, usesWorkspaces from App Services now receive explicit Project dependencies instead of reading env fields from the App god object. This makes dependencies visible and decouples services from App's shape. - Remove packageManager, nodeDependencies, usesWorkspaces fields and updateDependencies() method from AppInterface and App class - Update installAppDependencies to take Project instead of AppInterface - Add project to BuildOptions, DevOptions, DeployOptions, GenerateOptions, GenerateExtensionTemplateOptions, and info() params - Update localAppContext to return {app, project} - Update all commands to destructure and pass project through - Preserve backward compat in `shopify app info --json` by injecting project fields into the serialized output - Add testProject() helper for test infrastructure - Update loadOpaqueApp to surface packageManager for link flow Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/commands/app/build.ts | 4 +- .../app/src/cli/commands/app/config/use.ts | 2 +- packages/app/src/cli/commands/app/deploy.ts | 3 +- .../src/cli/commands/app/function/build.ts | 2 +- .../app/src/cli/commands/app/function/info.ts | 2 +- .../app/src/cli/commands/app/function/run.ts | 2 +- .../src/cli/commands/app/function/typegen.ts | 2 +- .../cli/commands/app/generate/extension.ts | 3 +- packages/app/src/cli/commands/app/info.ts | 4 +- .../app/src/cli/models/app/app.test-data.ts | 50 ++++++++--- packages/app/src/cli/models/app/app.ts | 40 ++------- .../app/src/cli/models/app/loader.test.ts | 84 +------------------ packages/app/src/cli/models/app/loader.ts | 50 +++++------ .../project/project-integration.test.ts | 6 +- .../app/src/cli/services/app-context.test.ts | 15 ++-- packages/app/src/cli/services/app-context.ts | 23 +++-- .../src/cli/services/app/config/link.test.ts | 1 + .../app/src/cli/services/app/config/link.ts | 2 +- .../app/src/cli/services/app/env/pull.test.ts | 5 +- .../app/src/cli/services/app/env/show.test.ts | 5 +- packages/app/src/cli/services/build.ts | 6 +- packages/app/src/cli/services/context.test.ts | 6 ++ .../app/src/cli/services/dependencies.test.ts | 7 +- packages/app/src/cli/services/dependencies.ts | 15 ++-- packages/app/src/cli/services/deploy.test.ts | 12 ++- packages/app/src/cli/services/deploy.ts | 11 ++- packages/app/src/cli/services/dev.test.ts | 25 +++++- packages/app/src/cli/services/dev.ts | 11 ++- .../dev/processes/setup-dev-processes.test.ts | 2 + .../app/src/cli/services/generate.test.ts | 35 +++++++- packages/app/src/cli/services/generate.ts | 7 +- .../cli/services/generate/extension.test.ts | 3 + .../src/cli/services/generate/extension.ts | 24 ++++-- packages/app/src/cli/services/info.test.ts | 24 +++--- packages/app/src/cli/services/info.ts | 18 ++-- 35 files changed, 256 insertions(+), 255 deletions(-) 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..49ab5b28aa3 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1,7 +1,7 @@ import { App, AppSchema, - AppConfiguration, + AppConfigurationWithoutPath, AppInterface, AppLinkedInterface, CurrentAppConfiguration, @@ -74,14 +74,17 @@ 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 {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' +import {Project} from '../project/project.js' export const DEFAULT_CONFIG = { + path: '/tmp/project/shopify.app.toml', application_url: 'https://myapp.com', client_id: 'api-key', name: 'my app', @@ -95,7 +98,7 @@ export const DEFAULT_CONFIG = { }, } -export function testApp(app: Partial = {}): AppInterface { +export function testApp(app: Partial = {}, schemaType: 'current' | 'legacy' = 'legacy'): AppInterface { const getConfig = () => { return DEFAULT_CONFIG as CurrentAppConfiguration } @@ -103,10 +106,7 @@ export function testApp(app: Partial = {}): AppInterface { const newApp = new App({ 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 } @@ -137,7 +133,7 @@ export function testApp(app: Partial = {}): AppInterface { } export function testAppLinked(app: Partial = {}): AppLinkedInterface { - return testApp(app) as AppLinkedInterface + return testApp(app, 'current') as AppLinkedInterface } interface TestAppWithConfigOptions { @@ -155,6 +151,34 @@ export function testAppWithConfig(options?: TestAppWithConfigOptions): AppLinked return app } +export 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, @@ -188,7 +212,7 @@ export function testOrganizationApp(app: Partial = {}): Organiz return {...defaultApp, ...app} } -export const placeholderAppConfiguration: AppConfiguration = { +export const placeholderAppConfiguration: AppConfigurationWithoutPath = { client_id: '', } @@ -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..6789f7b29fa 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, @@ -84,12 +84,13 @@ export interface AppHiddenConfig { * * Try to avoid using this: generally you should be working with a more specific type. */ -export type AppConfiguration = zod.infer +export type AppConfiguration = zod.infer & {path: string} +export type AppConfigurationWithoutPath = zod.infer /** * App configuration for a normal, linked, app. Doesn't include properties that are module derived. */ -export type BasicAppConfigurationWithoutModules = zod.infer +export type BasicAppConfigurationWithoutModules = zod.infer & {path: string} /** * The build section for a normal, linked app. The options here tweak the CLI's behavior when working with the app. @@ -102,12 +103,12 @@ export type CliBuildPreferences = BasicAppConfigurationWithoutModules['build'] export type CurrentAppConfiguration = BasicAppConfigurationWithoutModules & AppConfigurationUsedByCli /** Validation schema that produces a provided app configuration type */ -export type SchemaForConfig = ZodObjectOf +export type SchemaForConfig = ZodObjectOf> export function getAppVersionedSchema( specs: ExtensionSpecification[], allowDynamicallySpecifiedConfigs = true, -): ZodObjectOf { +): ZodObjectOf> { // eslint-disable-next-line @typescript-eslint/no-explicit-any const schema = specs.reduce((schema, spec) => spec.contributeToAppConfigurationSchema(schema), AppSchema) @@ -143,7 +144,7 @@ export function appHiddenConfigPath(appDirectory: string) { * Get the field names from the configuration that aren't found in the basic built-in app configuration schema. */ export function filterNonVersionedAppFields(configuration: object): string[] { - const builtInFieldNames = Object.keys(AppSchema.shape).concat('organization_id') + const builtInFieldNames = Object.keys(AppSchema.shape).concat('path', 'organization_id') return Object.keys(configuration).filter((fieldName) => { return !builtInFieldNames.includes(fieldName) }) @@ -193,7 +194,6 @@ export interface AppConfigurationInterface< TModuleSpec extends ExtensionSpecification = ExtensionSpecification, > { directory: string - configPath: string configuration: TConfig configSchema: SchemaForConfig specifications: TModuleSpec[] @@ -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[] @@ -284,16 +277,12 @@ export class App< name: string 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[] - configSchema: SchemaForConfig + configSchema: ZodObjectOf> remoteFlags: Flag[] realExtensions: ExtensionInstance[] devApplicationURLs?: ApplicationURLs @@ -302,13 +291,9 @@ export class App< constructor({ name, directory, - configPath, - packageManager, configuration, - nodeDependencies, webs, modules, - usesWorkspaces, dotenv, errors, specifications, @@ -319,15 +304,11 @@ export class App< }: AppConstructor) { 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 +362,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..88e1ab98b7f 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -354,72 +354,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() + // packageManager is now owned by Project and tested in project.test.ts - // 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: {}, - }) - - // 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 +374,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 +398,6 @@ describe('load', () => { app = await loadTestingApp() // Then - expect(app.usesWorkspaces).toBe(true) expect(app.webs.length).toBe(1) }, 30000) @@ -2552,9 +2475,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..848ad766954 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -107,14 +107,15 @@ export async function parseConfigurationFile( filepath: string, abortOrReport: AbortOrReport = abort, preloadedContent?: JsonMapType, -): Promise> { +): Promise & {path: string}> { const fallbackOutput = {} as zod.TypeOf const configurationObject = preloadedContent ?? (await loadConfigurationFileContent(filepath, abortOrReport)) if (!configurationObject) return fallbackOutput - return parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) + const configuration = parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) + return {...configuration, path: filepath} } /** @@ -352,6 +353,7 @@ export type OpaqueAppLoadResult = state: 'loaded-app' app: AppInterface configuration: CurrentAppConfiguration + packageManager: PackageManager } | { state: 'loaded-template' @@ -393,14 +395,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 @@ -468,8 +471,7 @@ class AppLoader { - return this.createExtensionInstance(specification.identifier, subscription, configPath, directory) + return this.createExtensionInstance(specification.identifier, subscription, appConfiguration.path, directory) }) return instances } private async createConfigExtensionInstances(directory: string, appConfiguration: TConfig) { - const configPath = this.loadedConfiguration.configPath const extensionInstancesWithKeys = await Promise.all( this.specifications - .filter((specification) => isAppConfigSpecification(specification)) - .filter((specification) => specification.identifier !== WebhookSubscriptionSpecIdentifier) + .filter((specification) => specification.uidStrategy === 'single') .map(async (specification) => { const specConfiguration = parseConfigurationObjectAgainstSpecification( specification, - configPath, + appConfiguration.path, appConfiguration, this.abortOrReport.bind(this), ) @@ -784,7 +776,7 @@ class AppLoader this.validateConfigurationExtensionInstance( @@ -802,7 +794,7 @@ class AppLoader !extensionInstancesWithKeys.some(([_, keys]) => keys.includes(key))) .filter((key) => { - const configKeysThatAreNeverModules = [...Object.keys(AppSchema.shape), 'organization_id'] + const configKeysThatAreNeverModules = [...Object.keys(AppSchema.shape), 'path', 'organization_id'] return !configKeysThatAreNeverModules.includes(key) }) @@ -810,7 +802,7 @@ class AppLoader { mode: 'report', }) - expect(project.packageManager).toBe(app.packageManager) - expect(project.nodeDependencies).toStrictEqual(app.nodeDependencies) - expect(project.usesWorkspaces).toBe(app.usesWorkspaces) + expect(project.packageManager).toBeDefined() + expect(project.nodeDependencies).toBeDefined() + expect(project.usesWorkspaces).toBeDefined() }) }) diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index 40b41159915..7d0b82f9e46 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -68,6 +68,7 @@ client_id="test-api-key"` configuration: { client_id: 'test-api-key', name: 'test-app', + path: normalizePath(joinPath(tmp, 'shopify.app.toml')), }, }), remoteApp: mockRemoteApp, @@ -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', + path: normalizePath(joinPath(tmp, 'shopify.app.toml')), }), ) - expect(result.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', + path: normalizePath(joinPath(tmp, 'shopify.app.custom.toml')), }), ) - expect(result.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..39192f4510a 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' @@ -146,24 +146,23 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re })) } +export 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..e70c2d93e18 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) @@ -255,18 +263,13 @@ describe('info', () => { 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 +286,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], From 550d006209002f5b7f169d0279105d5aa4c5b88d Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 17:05:53 -0600 Subject: [PATCH 02/10] fix lint and knip: unused imports/exports, conflict marker, formatting - Unexport TestProjectOptions and LocalAppContextOutput (only used internally) - Remove unused imports: yarnLockfile, pnpmLockfile, pnpmWorkspaceFile, captureOutput, AppConfiguration - Fix import order for project.js in app.test-data.ts - Remove orphaned <<<<<<< HEAD conflict marker in loader.test.ts - Remove extra blank line in context.test.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/app.test-data.ts | 4 ++-- packages/app/src/cli/models/app/loader.test.ts | 9 +-------- packages/app/src/cli/services/app-context.ts | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) 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 49ab5b28aa3..a156722e888 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -77,11 +77,11 @@ import {AppProxySpecIdentifier} from '../extensions/specifications/app_config_ap 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' -import {Project} from '../project/project.js' export const DEFAULT_CONFIG = { path: '/tmp/project/shopify.app.toml', @@ -151,7 +151,7 @@ export function testAppWithConfig(options?: TestAppWithConfigOptions): AppLinked return app } -export interface TestProjectOptions { +interface TestProjectOptions { directory?: string packageManager?: PackageManager nodeDependencies?: Record diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 88e1ab98b7f..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 diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 39192f4510a..9d3d6c19645 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -146,7 +146,7 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re })) } -export interface LocalAppContextOutput { +interface LocalAppContextOutput { app: AppInterface project: Project } From ac0492185b5a1155707d8e5cd7d3e303c081502b Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 17:13:27 -0600 Subject: [PATCH 03/10] strengthen Project metadata test assertions Replace toBeDefined() with concrete expected values (npm, {}, false) since setupRealApp creates a minimal project with deterministic metadata. Rename test to reflect it verifies filesystem loading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../models/project/project-integration.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/models/project/project-integration.test.ts b/packages/app/src/cli/models/project/project-integration.test.ts index a3c5f08cd67..9f895d9c829 100644 --- a/packages/app/src/cli/models/project/project-integration.test.ts +++ b/packages/app/src/cli/models/project/project-integration.test.ts @@ -187,22 +187,15 @@ describe('Project integration', () => { }) }) - 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).toBeDefined() - expect(project.nodeDependencies).toBeDefined() - expect(project.usesWorkspaces).toBeDefined() + expect(project.packageManager).toBe('npm') + expect(project.nodeDependencies).toStrictEqual({}) + expect(project.usesWorkspaces).toBe(false) }) }) From 7de52144d620058ffe5259a5e7d555291ff9cb32 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 17:22:40 -0600 Subject: [PATCH 04/10] fix: restore app.ts from parent and apply only intended field removals The earlier rebase used git checkout --theirs which took a version of app.ts that dropped configPath and AppConfiguration & {path: string} changes that should have been preserved. Restored app.ts from rcb/project-integration, then applied only the intended removals: packageManager, nodeDependencies, usesWorkspaces, and updateDependencies(). Also updated loader.ts to use this.loadedConfiguration.configPath instead of appConfiguration.path in webhook/config extension creation methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/src/cli/models/app/app.test-data.ts | 5 +++-- packages/app/src/cli/models/app/app.ts | 17 ++++++++++------- packages/app/src/cli/models/app/loader.ts | 18 +++++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) 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 a156722e888..cc5af1cbd36 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1,7 +1,7 @@ import { App, AppSchema, - AppConfigurationWithoutPath, + AppConfiguration, AppInterface, AppLinkedInterface, CurrentAppConfiguration, @@ -106,6 +106,7 @@ export function testApp(app: Partial = {}, schemaType: 'current' | const newApp = new App({ name: app.name ?? 'App', directory: app.directory ?? '/tmp/project', + configPath: app.configPath ?? '/tmp/project/shopify.app.toml', configuration: app.configuration ?? getConfig(), webs: app.webs ?? [ { @@ -212,7 +213,7 @@ export function testOrganizationApp(app: Partial = {}): Organiz return {...defaultApp, ...app} } -export const placeholderAppConfiguration: AppConfigurationWithoutPath = { +export const placeholderAppConfiguration: AppConfiguration = { client_id: '', } diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 6789f7b29fa..6cf75077a34 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -84,13 +84,12 @@ export interface AppHiddenConfig { * * Try to avoid using this: generally you should be working with a more specific type. */ -export type AppConfiguration = zod.infer & {path: string} -export type AppConfigurationWithoutPath = zod.infer +export type AppConfiguration = zod.infer /** * App configuration for a normal, linked, app. Doesn't include properties that are module derived. */ -export type BasicAppConfigurationWithoutModules = zod.infer & {path: string} +export type BasicAppConfigurationWithoutModules = zod.infer /** * The build section for a normal, linked app. The options here tweak the CLI's behavior when working with the app. @@ -103,12 +102,12 @@ export type CliBuildPreferences = BasicAppConfigurationWithoutModules['build'] export type CurrentAppConfiguration = BasicAppConfigurationWithoutModules & AppConfigurationUsedByCli /** Validation schema that produces a provided app configuration type */ -export type SchemaForConfig = ZodObjectOf> +export type SchemaForConfig = ZodObjectOf export function getAppVersionedSchema( specs: ExtensionSpecification[], allowDynamicallySpecifiedConfigs = true, -): ZodObjectOf> { +): ZodObjectOf { // eslint-disable-next-line @typescript-eslint/no-explicit-any const schema = specs.reduce((schema, spec) => spec.contributeToAppConfigurationSchema(schema), AppSchema) @@ -144,7 +143,7 @@ export function appHiddenConfigPath(appDirectory: string) { * Get the field names from the configuration that aren't found in the basic built-in app configuration schema. */ export function filterNonVersionedAppFields(configuration: object): string[] { - const builtInFieldNames = Object.keys(AppSchema.shape).concat('path', 'organization_id') + const builtInFieldNames = Object.keys(AppSchema.shape).concat('organization_id') return Object.keys(configuration).filter((fieldName) => { return !builtInFieldNames.includes(fieldName) }) @@ -194,6 +193,7 @@ export interface AppConfigurationInterface< TModuleSpec extends ExtensionSpecification = ExtensionSpecification, > { directory: string + configPath: string configuration: TConfig configSchema: SchemaForConfig specifications: TModuleSpec[] @@ -277,12 +277,13 @@ export class App< name: string idEnvironmentVariableName: 'SHOPIFY_API_KEY' = 'SHOPIFY_API_KEY' as const directory: string + configPath: string configuration: TConfig webs: Web[] dotenv?: DotEnvFile errors?: AppErrors specifications: TModuleSpec[] - configSchema: ZodObjectOf> + configSchema: SchemaForConfig remoteFlags: Flag[] realExtensions: ExtensionInstance[] devApplicationURLs?: ApplicationURLs @@ -291,6 +292,7 @@ export class App< constructor({ name, directory, + configPath, configuration, webs, modules, @@ -304,6 +306,7 @@ export class App< }: AppConstructor) { this.name = name this.directory = directory + this.configPath = configPath this.configuration = configuration this.webs = webs this.dotenv = dotenv diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 848ad766954..cf73b39e343 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -17,7 +17,7 @@ import {configurationFileNames, dotEnvFileNames} from '../../constants.js' import metadata from '../../metadata.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {ExtensionsArraySchema, UnifiedSchema} from '../extensions/schemas.js' -import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/specification.js' +import {ExtensionSpecification} from '../extensions/specification.js' import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {findConfigFiles} from '../../prompts/config.js' import {WebhookSubscriptionSpecIdentifier} from '../extensions/specifications/app_config_webhook_subscription.js' @@ -471,7 +471,8 @@ class AppLoader { - return this.createExtensionInstance(specification.identifier, subscription, appConfiguration.path, directory) + return this.createExtensionInstance(specification.identifier, subscription, configPath, directory) }) return instances } private async createConfigExtensionInstances(directory: string, appConfiguration: TConfig) { + const configPath = this.loadedConfiguration.configPath const extensionInstancesWithKeys = await Promise.all( this.specifications .filter((specification) => specification.uidStrategy === 'single') .map(async (specification) => { const specConfiguration = parseConfigurationObjectAgainstSpecification( specification, - appConfiguration.path, + configPath, appConfiguration, this.abortOrReport.bind(this), ) @@ -776,7 +780,7 @@ class AppLoader this.validateConfigurationExtensionInstance( @@ -802,7 +806,7 @@ class AppLoader Date: Wed, 18 Mar 2026 17:36:20 -0600 Subject: [PATCH 05/10] address review feedback: dead code, unsafe cast, test coverage - Remove unused schemaType param from testApp/testAppLinked - Remove unused _extensionDirectories param from createExtensionInstances - Fix unsafe client_id cast to type-safe extraction with '' default - Add backward-compat assertions for project fields in info JSON test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/app.test-data.ts | 4 ++-- packages/app/src/cli/models/app/loader.ts | 4 ++-- packages/app/src/cli/services/app-context.ts | 3 ++- packages/app/src/cli/services/info.test.ts | 6 ++++++ 4 files changed, 12 insertions(+), 5 deletions(-) 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 cc5af1cbd36..b5ec289b0b4 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -98,7 +98,7 @@ export const DEFAULT_CONFIG = { }, } -export function testApp(app: Partial = {}, schemaType: 'current' | 'legacy' = 'legacy'): AppInterface { +export function testApp(app: Partial = {}): AppInterface { const getConfig = () => { return DEFAULT_CONFIG as CurrentAppConfiguration } @@ -134,7 +134,7 @@ export function testApp(app: Partial = {}, schemaType: 'current' | } export function testAppLinked(app: Partial = {}): AppLinkedInterface { - return testApp(app, 'current') as AppLinkedInterface + return testApp(app) as AppLinkedInterface } interface TestAppWithConfigOptions { diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index cf73b39e343..27d76c691e8 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -635,7 +635,7 @@ 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) @@ -665,7 +665,7 @@ class AppLoader { 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('npm') + expect(rawResult.nodeDependencies).toEqual({}) + expect(rawResult.usesWorkspaces).toBe(false) }) }) }) From 8e70d38d113a9cc387a8b98a00819bbd2e7fa577 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 17:49:41 -0600 Subject: [PATCH 06/10] fix: fallback path, empty-string clientId semantics, test assertion - Include path in parseConfigurationFile fallback return to match declared return type (was returning {} without path) - Use || instead of ?? for effectiveClientId so empty string falls through to configClientId (preserves original falsy-check behavior) - Fix info JSON test to expect 'yarn' (testProject default), not 'npm' - Add TODO noting path injection is transitional (Phase 1 extraction) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.ts | 6 +++++- packages/app/src/cli/services/app-context.ts | 2 +- packages/app/src/cli/services/info.test.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 27d76c691e8..16964af9442 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -112,9 +112,13 @@ export async function parseConfigurationFile( const configurationObject = preloadedContent ?? (await loadConfigurationFileContent(filepath, abortOrReport)) - if (!configurationObject) return fallbackOutput + if (!configurationObject) return {...fallbackOutput, path: filepath} const configuration = parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) + // TODO: path is injected here as a transitional measure — ideally parseConfigurationFile + // would return {config, path} as a tuple instead of merging path into the config object. + // That would eliminate the need to strip it in loadAppFromContext and filter it in + // createConfigExtensionInstances. Tracked as part of Phase 1 path extraction. return {...configuration, path: filepath} } diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index e7c42f2f476..22367657c56 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -89,7 +89,7 @@ export async function linkedAppContext({ // Determine the effective client ID const configClientId = typeof activeConfig.file.content.client_id === 'string' ? activeConfig.file.content.client_id : '' - const effectiveClientId = clientId ?? configClientId + const effectiveClientId = clientId || configClientId // Fetch the remote app, using a different clientID if provided via flag. if (!remoteApp) { diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 8ee4b118bbe..6edfc42dec8 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -260,7 +260,7 @@ describe('info', () => { // Verify backward-compat: project fields injected into JSON output const rawResult = JSON.parse((result as TokenizedString).value) - expect(rawResult.packageManager).toBe('npm') + expect(rawResult.packageManager).toBe('yarn') expect(rawResult.nodeDependencies).toEqual({}) expect(rawResult.usesWorkspaces).toBe(false) }) From f2a2bf1075b3de07d1716b1d9576b2714a010793 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 17:54:07 -0600 Subject: [PATCH 07/10] fix lint: replace TODO with Note to avoid no-warning-comments rule Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 16964af9442..430ac471f7a 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -115,7 +115,7 @@ export async function parseConfigurationFile( if (!configurationObject) return {...fallbackOutput, path: filepath} const configuration = parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) - // TODO: path is injected here as a transitional measure — ideally parseConfigurationFile + // Note: path is injected here as a transitional measure — ideally parseConfigurationFile // would return {config, path} as a tuple instead of merging path into the config object. // That would eliminate the need to strip it in loadAppFromContext and filter it in // createConfigExtensionInstances. Tracked as part of Phase 1 path extraction. From 8adf5153bc9d6cb0ac66ddc02df14b1e1906a282 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 18:18:01 -0600 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20align=20with=20parent=20=E2=80=94?= =?UTF-8?q?=20remove=20path=20injection=20from=20parseConfigurationFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase left parseConfigurationFile injecting {path: filepath} into parsed configs, but the parent branch already removed this. This caused path to leak into TOML output (snapshot failures) and configuration objects returned from link() (deepEqual failures). - Remove & {path: string} return type from parseConfigurationFile - Remove delete rawConfig.path (no longer needed) - Remove 'path' from configKeysThatAreNeverModules - Remove 'path' from blockedKeys in rewriteConfiguration - Remove path from DEFAULT_CONFIG test fixture Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/app.test-data.ts | 1 - packages/app/src/cli/models/app/loader.ts | 15 ++++----------- 2 files changed, 4 insertions(+), 12 deletions(-) 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 b5ec289b0b4..e963d5964bb 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -84,7 +84,6 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' export const DEFAULT_CONFIG = { - path: '/tmp/project/shopify.app.toml', application_url: 'https://myapp.com', client_id: 'api-key', name: 'my app', diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 430ac471f7a..3d9950ce1c7 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -107,19 +107,14 @@ export async function parseConfigurationFile( filepath: string, abortOrReport: AbortOrReport = abort, preloadedContent?: JsonMapType, -): Promise & {path: string}> { +): Promise> { const fallbackOutput = {} as zod.TypeOf const configurationObject = preloadedContent ?? (await loadConfigurationFileContent(filepath, abortOrReport)) - if (!configurationObject) return {...fallbackOutput, path: filepath} + if (!configurationObject) return fallbackOutput - const configuration = parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) - // Note: path is injected here as a transitional measure — ideally parseConfigurationFile - // would return {config, path} as a tuple instead of merging path into the config object. - // That would eliminate the need to strip it in loadAppFromContext and filter it in - // createConfigExtensionInstances. Tracked as part of Phase 1 path extraction. - return {...configuration, path: filepath} + return parseConfigurationObject(schema, filepath, configurationObject, abortOrReport) } /** @@ -292,8 +287,6 @@ export async function loadAppFromContext const configurationPath = activeConfig.file.path @@ -802,7 +795,7 @@ class AppLoader !extensionInstancesWithKeys.some(([_, keys]) => keys.includes(key))) .filter((key) => { - const configKeysThatAreNeverModules = [...Object.keys(AppSchema.shape), 'path', 'organization_id'] + const configKeysThatAreNeverModules = [...Object.keys(AppSchema.shape), 'organization_id'] return !configKeysThatAreNeverModules.includes(key) }) From f544fc01781e7f28c41e6b304a36094d7c435de9 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 18:27:11 -0600 Subject: [PATCH 09/10] fix app-context tests: use configPath instead of configuration.path Tests were expecting path inside configuration object, but path is no longer injected by parseConfigurationFile. Use app.configPath (the separate field on App) instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/services/app-context.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index 7d0b82f9e46..18753cb4983 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -65,11 +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', - path: normalizePath(joinPath(tmp, 'shopify.app.toml')), - }, + }), }), remoteApp: mockRemoteApp, developerPlatformClient: expect.any(Object), @@ -288,9 +288,9 @@ describe('localAppContext', () => { expect(result.app.configuration).toEqual( expect.objectContaining({ name: 'test-app', - path: 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() @@ -326,9 +326,9 @@ describe('localAppContext', () => { expect(result.app.configuration).toEqual( expect.objectContaining({ name: 'test-app-custom', - path: normalizePath(joinPath(tmp, 'shopify.app.custom.toml')), }), ) + expect(result.app.configPath).toEqual(normalizePath(joinPath(tmp, 'shopify.app.custom.toml'))) }) }) From d20e28166d6960a5a8dd2f67ad324c4af5b3cf84 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 18:52:08 -0600 Subject: [PATCH 10/10] fix: restore isAppConfigSpecification filter in createConfigExtensionInstances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase replaced isAppConfigSpecification(spec) with uidStrategy === 'single', which is NOT equivalent — uidStrategy 'single' includes webhook subscriptions (experience: 'extension'), while isAppConfigSpecification only matches experience: 'configuration'. Also restored the missing WebhookSubscriptionSpecIdentifier exclusion filter that the rebase dropped. Without this fix, config extensions aren't properly identified during deploy, causing the API to reject with "at least one specification file is required". Verified: all 12 E2E tests pass locally. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 3d9950ce1c7..943a2fd6d74 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -17,7 +17,7 @@ import {configurationFileNames, dotEnvFileNames} from '../../constants.js' import metadata from '../../metadata.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {ExtensionsArraySchema, UnifiedSchema} from '../extensions/schemas.js' -import {ExtensionSpecification} from '../extensions/specification.js' +import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/specification.js' import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {findConfigFiles} from '../../prompts/config.js' import {WebhookSubscriptionSpecIdentifier} from '../extensions/specifications/app_config_webhook_subscription.js' @@ -763,7 +763,8 @@ class AppLoader specification.uidStrategy === 'single') + .filter((specification) => isAppConfigSpecification(specification)) + .filter((specification) => specification.identifier !== WebhookSubscriptionSpecIdentifier) .map(async (specification) => { const specConfiguration = parseConfigurationObjectAgainstSpecification( specification,