diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index c765e6e6f6c..c3aa272a3de 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -570,6 +570,36 @@ describe('draftMessages', async () => { }) }) +describe('devSessionWatchConfig', () => { + test('returns undefined for extension experience (watch everything)', async () => { + const extensionInstance = await testUIExtension({type: 'ui_extension'}) + expect(extensionInstance.devSessionWatchConfig).toBeUndefined() + }) + + test('returns empty paths for configuration experience (watch nothing)', async () => { + const extensionInstance = await testAppConfigExtensions() + expect(extensionInstance.devSessionWatchConfig).toEqual({paths: []}) + }) + + test('delegates to specification devSessionWatchConfig when defined', async () => { + const config = functionConfiguration() + config.build = { + watch: 'src/**/*.rs', + wasm_opt: true, + } + const extensionInstance = await testFunctionExtension({config, dir: '/tmp/my-function'}) + const watchConfig = extensionInstance.devSessionWatchConfig + expect(watchConfig).toBeDefined() + expect(watchConfig!.paths).toContain(joinPath('/tmp/my-function', 'src/**/*.rs')) + }) + + test('returns undefined for function extension without build.watch', async () => { + const config = functionConfiguration() + const extensionInstance = await testFunctionExtension({config}) + expect(extensionInstance.devSessionWatchConfig).toBeUndefined() + }) +}) + describe('watchedFiles', async () => { test('returns files based on devSessionWatchPaths when defined', async () => { await inTemporaryDirectory(async (tmpDir) => { @@ -607,6 +637,58 @@ describe('watchedFiles', async () => { }) }) + test('respects custom ignore paths from devSessionWatchConfig', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - create an extension with a spec that defines custom ignore paths + const config = functionConfiguration() + config.build = { + watch: '**/*', + wasm_opt: true, + } + const extensionInstance = await testFunctionExtension({ + config, + dir: tmpDir, + }) + + // Override devSessionWatchConfig to include ignore paths + vi.spyOn(extensionInstance, 'devSessionWatchConfig', 'get').mockReturnValue({ + paths: [joinPath(tmpDir, '**/*')], + ignore: ['**/ignored-dir/**'], + }) + + // Create files - one in a normal dir, one in the ignored dir + const srcDir = joinPath(tmpDir, 'src') + const ignoredDir = joinPath(tmpDir, 'ignored-dir') + await mkdir(srcDir) + await mkdir(ignoredDir) + await writeFile(joinPath(srcDir, 'index.js'), 'code') + await writeFile(joinPath(ignoredDir, 'should-be-ignored.js'), 'ignored') + + // When + const watchedFiles = extensionInstance.watchedFiles() + + // Then + expect(watchedFiles).toContain(joinPath(srcDir, 'index.js')) + expect(watchedFiles).not.toContain(joinPath(ignoredDir, 'should-be-ignored.js')) + }) + }) + + test('returns empty watched files for configuration extensions', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionInstance = await testAppConfigExtensions(false, tmpDir) + + // Create files that should not be watched + await writeFile(joinPath(tmpDir, 'some-file.ts'), 'code') + + // When + const watchedFiles = extensionInstance.watchedFiles() + + // Then - configuration extensions default to empty paths, so no files watched + expect(watchedFiles).toHaveLength(0) + }) + }) + test('returns all files when devSessionWatchPaths is undefined', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 318617ab2b7..6da9a76ebf7 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,6 +1,6 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' -import {ExtensionFeature, ExtensionSpecification} from './specification.js' +import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js' import {bundleThemeExtension} from '../../services/extensions/bundle.js' @@ -277,20 +277,15 @@ export class ExtensionInstance joinPath(this.directory, path)) - - watchPaths.push(joinPath(this.directory, 'locales', '**.json')) - watchPaths.push(joinPath(this.directory, '**', '!(.)*.graphql')) - watchPaths.push(joinPath(this.directory, '**.toml')) + // Custom watch configuration for dev sessions + // Return undefined to watch everything (default for 'extension' experience) + // Return a config with empty paths to watch nothing (default for 'configuration' experience) + get devSessionWatchConfig(): DevSessionWatchConfig | undefined { + if (this.specification.devSessionWatchConfig) { + return this.specification.devSessionWatchConfig(this) + } - return watchPaths + return this.specification.experience === 'configuration' ? {paths: []} : undefined } async watchConfigurationPaths() { @@ -436,20 +431,31 @@ export class ExtensionInstance globSync(pattern, { cwd: this.directory, absolute: true, followSymbolicLinks: false, - ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/*.swp', '**/generated/**'], + ignore, }), ) watchedFiles.push(...files.flat()) - // Add imported files from outside the extension directory unless custom watch paths are defined - if (!this.devSessionCustomWatchPaths) { + // Add imported files from outside the extension directory unless custom watch config is defined + if (!watchConfig) { const importedFiles = this.scanImports() watchedFiles.push(...importedFiles) } diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f7b1bea33c..94907693f10 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -136,6 +136,20 @@ export interface ExtensionSpecification Promise + + /** + * Custom watch configuration for dev sessions. + * Return a DevSessionWatchConfig with paths to watch and optionally paths to ignore, + * or undefined to watch all files in the extension directory. + */ + devSessionWatchConfig?: (extension: ExtensionInstance) => DevSessionWatchConfig | undefined +} + +export interface DevSessionWatchConfig { + /** Absolute paths or globs to watch */ + paths: string[] + /** Glob patterns to ignore. When provided, replaces the default ignore list entirely. */ + ignore?: string[] } /** @@ -294,6 +308,7 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ @@ -305,6 +320,7 @@ export function createContractBasedModuleSpecification { let parsedConfig = configWithoutFirstClassFields(config) if (spec.appModuleFeatures().includes('localization')) { diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 0aea2ee661a..e8b132e233b 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -1,8 +1,33 @@ -import {createContractBasedModuleSpecification} from '../specification.js' +import {createExtensionSpecification} from '../specification.js' +import {BaseConfigType, ZodSchemaType} from '../schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' +import {joinPath} from '@shopify/cli-kit/node/path' -const adminSpecificationSpec = createContractBasedModuleSpecification({ +const AdminSchema = zod.object({ + admin: zod + .object({ + static_root: zod.string().optional(), + }) + .optional(), +}) + +type AdminConfigType = zod.infer & BaseConfigType + +const adminSpecificationSpec = createExtensionSpecification({ identifier: 'admin', uidStrategy: 'single', + experience: 'configuration', + schema: AdminSchema as ZodSchemaType, + deployConfig: async (config, _) => { + return {admin: config.admin} + }, + devSessionWatchConfig: (extension) => { + const staticRoot = extension.configuration.admin?.static_root + if (!staticRoot) return {paths: []} + + const path = joinPath(extension.directory, staticRoot, '**/*') + return {paths: [path], ignore: []} + }, transformRemoteToLocal: (remoteContent) => { return { admin: { diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 9f343ac6611..d40d83e523b 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -90,6 +90,18 @@ const functionSpec = createExtensionSpecification({ appModuleFeatures: (_) => ['function'], buildConfig: {mode: 'function'}, getOutputRelativePath: (_extension: ExtensionInstance) => joinPath('dist', 'index.wasm'), + devSessionWatchConfig: (extension: ExtensionInstance) => { + const config = extension.configuration + if (!config.build || !config.build.watch) return undefined + + const paths = [config.build.watch].flat().map((path) => joinPath(extension.directory, path)) + + paths.push(joinPath(extension.directory, 'locales', '**.json')) + paths.push(joinPath(extension.directory, '**', '!(.)*.graphql')) + paths.push(joinPath(extension.directory, '**.toml')) + + return {paths} + }, clientSteps: [ { lifecycle: 'deploy', diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index b69565d39bb..725604a59d1 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -274,15 +274,7 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/*.test.*', - '**/dist/**', - '**/*.swp', - '**/generated/**', - '**/.gitignore', - ], + ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, persistent: true, }) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index e5995b140c7..ca55158bae2 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -118,15 +118,7 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') this.watcher = chokidar.watch(watchPaths, { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/*.test.*', - '**/dist/**', - '**/*.swp', - '**/generated/**', - '**/.gitignore', - ], + ignored: ['**/node_modules/**', '**/.git/**'], persistent: true, ignoreInitial: true, })