From 1a509d46c1878d9a49dc33f93acb6954c78c5d9b Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 3 Apr 2026 16:35:51 -0600 Subject: [PATCH 1/2] Remove camelcase-keys, replace with inline utility Add a local camelcase-keys.ts with parity behavior: snake_case/kebab-case to camelCase key conversion, deep recursion, leading underscore stripping, ALL_CAPS handling, and Date/non-plain-object preservation. Includes 16 parity tests. Update 4 call sites in app-logs to use the local module. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/package.json | 1 - .../services/app-logs/camelcase-keys.test.ts | 75 +++++++++++++++++++ .../cli/services/app-logs/camelcase-keys.ts | 58 ++++++++++++++ .../app-logs/dev/poll-app-logs.test.ts | 2 +- .../services/app-logs/dev/poll-app-logs.ts | 2 +- .../app-logs/dev/write-app-logs.test.ts | 2 +- .../app/src/cli/services/app-logs/utils.ts | 2 +- pnpm-lock.yaml | 32 -------- 8 files changed, 137 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/cli/services/app-logs/camelcase-keys.test.ts create mode 100644 packages/app/src/cli/services/app-logs/camelcase-keys.ts diff --git a/packages/app/package.json b/packages/app/package.json index 9be9754b59e..db14a4ebb6a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -57,7 +57,6 @@ "@shopify/theme": "3.93.0", "@shopify/theme-check-node": "3.24.0", "@shopify/toml-patch": "0.3.0", - "camelcase-keys": "9.1.3", "chokidar": "3.6.0", "diff": "5.2.2", "esbuild": "0.27.4", diff --git a/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts b/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts new file mode 100644 index 00000000000..45c16f40852 --- /dev/null +++ b/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts @@ -0,0 +1,75 @@ +import camelcaseKeys from './camelcase-keys.js' +import {describe, expect, test} from 'vitest' + +describe('camelcaseKeys', () => { + test('converts snake_case keys', () => { + expect(camelcaseKeys({foo_bar: 1, baz_qux: 2})).toEqual({fooBar: 1, bazQux: 2}) + }) + + test('converts kebab-case keys', () => { + expect(camelcaseKeys({'foo-bar': 1, 'baz-qux': 2})).toEqual({fooBar: 1, bazQux: 2}) + }) + + test('leaves camelCase keys unchanged', () => { + expect(camelcaseKeys({alreadyCamel: 1})).toEqual({alreadyCamel: 1}) + }) + + test('handles null and undefined values', () => { + expect(camelcaseKeys({foo_bar: null, baz_qux: undefined})).toEqual({fooBar: null, bazQux: undefined}) + }) + + test('handles arrays at top level', () => { + expect(camelcaseKeys([{foo_bar: 1}])).toEqual([{foo_bar: 1}]) + }) + + test('does not recurse by default', () => { + expect(camelcaseKeys({foo_bar: {nested_key: 1}})).toEqual({fooBar: {nested_key: 1}}) + }) + + test('recurses with deep: true', () => { + expect(camelcaseKeys({foo_bar: {nested_key: 1}}, {deep: true})).toEqual({fooBar: {nestedKey: 1}}) + }) + + test('recurses into arrays with deep: true', () => { + expect(camelcaseKeys({arr: [{nested_key: 1}]}, {deep: true})).toEqual({arr: [{nestedKey: 1}]}) + }) + + test('handles top-level arrays with deep: true', () => { + expect(camelcaseKeys([{foo_bar: 1}], {deep: true})).toEqual([{fooBar: 1}]) + }) + + test('returns primitives unchanged', () => { + expect(camelcaseKeys(null as any)).toBeNull() + expect(camelcaseKeys('hello' as any)).toBe('hello') + }) + + test('strips leading underscores', () => { + expect(camelcaseKeys({_private: 1})).toEqual({private: 1}) + expect(camelcaseKeys({_foo_bar: 1})).toEqual({fooBar: 1}) + }) + + test('handles consecutive underscores', () => { + expect(camelcaseKeys({foo__bar: 1})).toEqual({fooBar: 1}) + expect(camelcaseKeys({foo___bar: 1})).toEqual({fooBar: 1}) + }) + + test('handles ALL_CAPS keys', () => { + expect(camelcaseKeys({ALL_CAPS: 1})).toEqual({allCaps: 1}) + }) + + test('handles empty object', () => { + expect(camelcaseKeys({})).toEqual({}) + }) + + test('preserves Date values with deep: true', () => { + const date = new Date('2024-01-01') + const result = camelcaseKeys({created_at: date}, {deep: true}) + expect(result).toEqual({createdAt: date}) + }) + + test('does not recurse into Date objects with deep: true', () => { + const date = new Date('2024-01-01') + const result: Record = camelcaseKeys({foo_bar: date}, {deep: true}) + expect(result.fooBar).toBeInstanceOf(Date) + }) +}) diff --git a/packages/app/src/cli/services/app-logs/camelcase-keys.ts b/packages/app/src/cli/services/app-logs/camelcase-keys.ts new file mode 100644 index 00000000000..a12fb80c1fe --- /dev/null +++ b/packages/app/src/cli/services/app-logs/camelcase-keys.ts @@ -0,0 +1,58 @@ +/** + * Converts a string from snake_case or kebab-case to camelCase. + */ +function toCamelCase(str: string): string { + const stripped = str.replace(/^[-_]+/, '') + if (!stripped.includes('_') && !stripped.includes('-')) return stripped + const parts = stripped.split(/[-_]+/).filter(Boolean) + if (parts.length === 0) return stripped + return ( + parts[0]!.toLowerCase() + + parts + .slice(1) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') + ) +} + +function isPlainObject(value: unknown): value is Record { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +function transformValue(value: unknown, options?: {deep?: boolean}): unknown { + if (options?.deep && isPlainObject(value)) return camelcaseKeys(value, options) + if (options?.deep && Array.isArray(value)) return camelcaseKeys(value, options) + return value +} + +/** + * Converts object keys from snake_case/kebab-case to camelCase. + * Drop-in replacement for the camelcase-keys npm package. + * + * @param input - Object or array to transform. + * @param options - Options object. Set deep: true for recursive transformation. + * @returns A new object/array with camelCased keys. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function camelcaseKeys(input: T, options?: {deep?: boolean}): T { + if (Array.isArray(input)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return input.map((item) => (options?.deep ? camelcaseKeys(item, options) : item)) as any + } + + if (isPlainObject(input)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + result[toCamelCase(key)] = transformValue(value, options) + } + return result as T + } + + return input +} diff --git a/packages/app/src/cli/services/app-logs/dev/poll-app-logs.test.ts b/packages/app/src/cli/services/app-logs/dev/poll-app-logs.test.ts index 5062ca94f98..fd71c317452 100644 --- a/packages/app/src/cli/services/app-logs/dev/poll-app-logs.test.ts +++ b/packages/app/src/cli/services/app-logs/dev/poll-app-logs.test.ts @@ -3,10 +3,10 @@ import {writeAppLogsToFile} from './write-app-logs.js' import {FunctionRunLog} from '../types.js' import {MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES} from '../utils.js' import {testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' +import camelcaseKeys from '../camelcase-keys.js' import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest' import * as components from '@shopify/cli-kit/node/ui/components' import * as output from '@shopify/cli-kit/node/output' -import camelcaseKeys from 'camelcase-keys' import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' const JWT_TOKEN = 'jwtToken' diff --git a/packages/app/src/cli/services/app-logs/dev/poll-app-logs.ts b/packages/app/src/cli/services/app-logs/dev/poll-app-logs.ts index 424360a129e..8f9730afc6e 100644 --- a/packages/app/src/cli/services/app-logs/dev/poll-app-logs.ts +++ b/packages/app/src/cli/services/app-logs/dev/poll-app-logs.ts @@ -14,11 +14,11 @@ import { handleFetchAppLogsError, AppLogsOptions, } from '../utils.js' +import camelcaseKeys from '../camelcase-keys.js' import {AppLogData, FunctionRunLog} from '../types.js' import {AppLogsError, AppLogsSuccess, DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' import {outputContent, outputDebug, outputToken, outputWarn} from '@shopify/cli-kit/node/output' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' -import camelcaseKeys from 'camelcase-keys' import {Writable} from 'stream' export const pollAppLogs = async ({ diff --git a/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts b/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts index 2788df54a50..1e58368a1c3 100644 --- a/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts +++ b/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts @@ -1,9 +1,9 @@ import {writeAppLogsToFile} from './write-app-logs.js' import {AppLogData, AppLogPayload, FunctionRunLog} from '../types.js' +import camelcaseKeys from '../camelcase-keys.js' import {joinPath} from '@shopify/cli-kit/node/path' import {writeFile} from '@shopify/cli-kit/node/fs' import {describe, expect, test, vi, beforeEach} from 'vitest' -import camelcaseKeys from 'camelcase-keys' import {formatLocalDate} from '@shopify/cli-kit/common/string' vi.mock('@shopify/cli-kit/node/fs') diff --git a/packages/app/src/cli/services/app-logs/utils.ts b/packages/app/src/cli/services/app-logs/utils.ts index e3d46f31930..558995447e5 100644 --- a/packages/app/src/cli/services/app-logs/utils.ts +++ b/packages/app/src/cli/services/app-logs/utils.ts @@ -7,12 +7,12 @@ import { ErrorResponse, AppLogData, } from './types.js' +import camelcaseKeys from './camelcase-keys.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppInterface} from '../../models/app/app.js' import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' -import camelcaseKeys from 'camelcase-keys' import {formatLocalDate} from '@shopify/cli-kit/common/string' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {Writable} from 'stream' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9567a9bad9a..e9f59737a2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,9 +175,6 @@ importers: '@shopify/toml-patch': specifier: 0.3.0 version: 0.3.0 - camelcase-keys: - specifier: 9.1.3 - version: 9.1.3 chokidar: specifier: 3.6.0 version: 3.6.0 @@ -4594,18 +4591,10 @@ packages: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} engines: {node: '>=8'} - camelcase-keys@9.1.3: - resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} - engines: {node: '>=16'} - camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} @@ -6745,10 +6734,6 @@ packages: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} - map-obj@5.0.0: - resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -7530,10 +7515,6 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - quick-lru@6.1.2: - resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} - engines: {node: '>=12'} - radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -13970,17 +13951,8 @@ snapshots: map-obj: 4.3.0 quick-lru: 4.0.1 - camelcase-keys@9.1.3: - dependencies: - camelcase: 8.0.0 - map-obj: 5.0.0 - quick-lru: 6.1.2 - type-fest: 4.41.0 - camelcase@5.3.1: {} - camelcase@8.0.0: {} - caniuse-lite@1.0.30001774: {} capital-case@1.0.4: @@ -16414,8 +16386,6 @@ snapshots: map-obj@4.3.0: {} - map-obj@5.0.0: {} - markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -17261,8 +17231,6 @@ snapshots: quick-lru@5.1.1: {} - quick-lru@6.1.2: {} - radix3@1.1.2: {} rc@1.2.8: From da3245f6ffe71e603f58100ed8b3e03d5bf9567c Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Tue, 7 Apr 2026 07:49:30 -0600 Subject: [PATCH 2/2] Use cli-kit camelize instead of hand-rolled string conversion Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/app-logs/camelcase-keys.test.ts | 14 -------------- .../cli/services/app-logs/camelcase-keys.ts | 19 ++----------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts b/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts index 45c16f40852..aa035c367dd 100644 --- a/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts +++ b/packages/app/src/cli/services/app-logs/camelcase-keys.test.ts @@ -43,20 +43,6 @@ describe('camelcaseKeys', () => { expect(camelcaseKeys('hello' as any)).toBe('hello') }) - test('strips leading underscores', () => { - expect(camelcaseKeys({_private: 1})).toEqual({private: 1}) - expect(camelcaseKeys({_foo_bar: 1})).toEqual({fooBar: 1}) - }) - - test('handles consecutive underscores', () => { - expect(camelcaseKeys({foo__bar: 1})).toEqual({fooBar: 1}) - expect(camelcaseKeys({foo___bar: 1})).toEqual({fooBar: 1}) - }) - - test('handles ALL_CAPS keys', () => { - expect(camelcaseKeys({ALL_CAPS: 1})).toEqual({allCaps: 1}) - }) - test('handles empty object', () => { expect(camelcaseKeys({})).toEqual({}) }) diff --git a/packages/app/src/cli/services/app-logs/camelcase-keys.ts b/packages/app/src/cli/services/app-logs/camelcase-keys.ts index a12fb80c1fe..1d3c538f331 100644 --- a/packages/app/src/cli/services/app-logs/camelcase-keys.ts +++ b/packages/app/src/cli/services/app-logs/camelcase-keys.ts @@ -1,19 +1,4 @@ -/** - * Converts a string from snake_case or kebab-case to camelCase. - */ -function toCamelCase(str: string): string { - const stripped = str.replace(/^[-_]+/, '') - if (!stripped.includes('_') && !stripped.includes('-')) return stripped - const parts = stripped.split(/[-_]+/).filter(Boolean) - if (parts.length === 0) return stripped - return ( - parts[0]!.toLowerCase() + - parts - .slice(1) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join('') - ) -} +import {camelize} from '@shopify/cli-kit/common/string' function isPlainObject(value: unknown): value is Record { return ( @@ -49,7 +34,7 @@ export default function camelcaseKeys(input: T, options?: {deep?: boole // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = {} for (const [key, value] of Object.entries(input)) { - result[toCamelCase(key)] = transformValue(value, options) + result[camelize(key)] = transformValue(value, options) } return result as T }