Repository: Shopify/cli
Pull request #5677: Add dependency on @shopify/toml-patch
Author: login: shauns
Created: 2025-04-22T13:42:15Z | Merged: 2025-04-29T08:59:35Z

### WHY are these changes introduced?

To improve TOML file manipulation by adding a WASM-based implementation for patching app configuration files.

### WHAT is this pull request doing?

- Adds `@shopify/toml-patch` dependency (v0.2.0) to handle TOML file modifications
- Adds a feature flag `SHOPIFY_CLI_USE_WASM_TOML_PATCH` to toggle between implementations. Off by default.

### How to test your changes?

1. Set the environment variable `SHOPIFY_CLI_USE_WASM_TOML_PATCH=true`
2. Make a new DP2 app
3. Edit the TOML file to put some formatting/comments in
4. Run `shopify app dev`
5. See that `automatically_update_urls_on_dev` is set -- i.e. the file is written to -- but your formatting is not broken



Diff:
diff --git a/.changeset/seven-jeans-run.md b/.changeset/seven-jeans-run.md
new file mode 100644
index 00000000000..0af97cee74a
--- /dev/null
+++ b/.changeset/seven-jeans-run.md
@@ -0,0 +1,6 @@
+---
+'@shopify/app': patch
+'@shopify/cli': patch
+---
+
+Added formatting and comment preserving TOML support via @shopify/toml-patch; opt-in by setting "SHOPIFY_CLI_USE_WASM_TOML_PATCH"
diff --git a/packages/app/package.json b/packages/app/package.json
index b5fe8f66b11..7852d9343cf 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -59,6 +59,7 @@
     "@shopify/polaris-icons": "8.11.1",
     "@shopify/theme": "3.78.0",
     "@shopify/theme-check-node": "3.15.1",
+    "@shopify/toml-patch": "0.3.0",
     "body-parser": "1.20.3",
     "camelcase-keys": "9.1.3",
     "chokidar": "3.6.0",
diff --git a/packages/app/src/cli/constants.ts b/packages/app/src/cli/constants.ts
index 150690cf1bf..bc8ac1b8618 100644
--- a/packages/app/src/cli/constants.ts
+++ b/packages/app/src/cli/constants.ts
@@ -5,6 +5,7 @@ export const environmentVariableNames = {
   enableAppLogPolling: 'SHOPIFY_CLI_ENABLE_APP_LOG_POLLING',
   templatesJsonPath: 'SHOPIFY_CLI_APP_TEMPLATES_JSON_PATH',
   mkcertBinaryPath: 'SHOPIFY_CLI_MKCERT_BINARY',
+  useWasmTomlPatch: 'SHOPIFY_CLI_USE_WASM_TOML_PATCH',
 }
 
 export const configurationFileNames = {
diff --git a/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts b/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts
index 658b1f564f1..5ed2652d7f1 100644
--- a/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts
+++ b/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts
@@ -6,9 +6,10 @@ import {
 } from './patch-app-configuration-file.js'
 import {getAppVersionedSchema} from '../../models/app/app.js'
 import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js'
+import {environmentVariableNames} from '../../constants.js'
 import {readFile, writeFileSync, inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
 import {joinPath} from '@shopify/cli-kit/node/path'
-import {describe, expect, test} from 'vitest'
+import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
 
 const defaultToml = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
 client_id = "12345"
@@ -119,7 +120,15 @@ describe('patchAppHiddenConfigFile', () => {
   })
 })
 
-describe('setAppConfigValue', () => {
+describe.each([false, true])('unsetAppConfigValue (useWasmTomlPatch: %s)', (useWasmTomlPatch) => {
+  beforeEach(() => {
+    vi.stubEnv(environmentVariableNames.useWasmTomlPatch, useWasmTomlPatch.toString())
+  })
+
+  afterEach(() => {
+    vi.unstubAllEnvs()
+  })
+
   test('sets a top-level value in the configuration', async () => {
     await inTemporaryDirectory(async (tmpDir) => {
       const configPath = writeDefaulToml(tmpDir)
@@ -156,7 +165,15 @@ describe('setAppConfigValue', () => {
   })
 })
 
-describe('unsetAppConfigValue', () => {
+describe.each([false, true])('unsetAppConfigValue (useWasmTomlPatch: %s)', (useWasmTomlPatch) => {
+  beforeEach(() => {
+    vi.stubEnv(environmentVariableNames.useWasmTomlPatch, useWasmTomlPatch.toString())
+  })
+
+  afterEach(() => {
+    vi.unstubAllEnvs()
+  })
+
   test('unsets a top-level value in the configuration', async () => {
     await inTemporaryDirectory(async (tmpDir) => {
       const configPath = writeDefaulToml(tmpDir)
@@ -185,7 +202,15 @@ describe('unsetAppConfigValue', () => {
   })
 })
 
-describe('setManyAppConfigValues', () => {
+describe.each([false, true])('setManyAppConfigValues (useWasmTomlPatch: %s)', (useWasmTomlPatch) => {
+  beforeEach(() => {
+    vi.stubEnv(environmentVariableNames.useWasmTomlPatch, useWasmTomlPatch.toString())
+  })
+
+  afterEach(() => {
+    vi.unstubAllEnvs()
+  })
+
   test('sets multiple top-level values in the configuration', async () => {
     await inTemporaryDirectory(async (tmpDir) => {
       const configPath = writeDefaulToml(tmpDir)
diff --git a/packages/app/src/cli/services/app/patch-app-configuration-file.ts b/packages/app/src/cli/services/app/patch-app-configuration-file.ts
index 8a44b8a42bf..4be1e49586c 100644
--- a/packages/app/src/cli/services/app/patch-app-configuration-file.ts
+++ b/packages/app/src/cli/services/app/patch-app-configuration-file.ts
@@ -1,9 +1,11 @@
 import {addDefaultCommentsToToml} from './write-app-configuration-file.js'
 import {AppHiddenConfig} from '../../models/app/app.js'
+import {environmentVariableNames} from '../../constants.js'
 import {deepMergeObjects} from '@shopify/cli-kit/common/object'
 import {readFile, writeFile} from '@shopify/cli-kit/node/fs'
 import {zod} from '@shopify/cli-kit/node/schema'
 import {decodeToml, encodeToml} from '@shopify/cli-kit/node/toml'
+import {isTruthy} from '@shopify/cli-kit/node/context/utilities'
 
 export interface PatchTomlOptions {
   path: string
@@ -11,6 +13,12 @@ export interface PatchTomlOptions {
   schema?: zod.AnyZodObject
 }
 
+type TomlPatchValue = string | number | boolean | undefined | string[]
+
+function shouldUseWasmTomlPatch(env = process.env): boolean {
+  return isTruthy(env[environmentVariableNames.useWasmTomlPatch])
+}
+
 /**
  * Updates an app configuration file with the given patch.
  *
@@ -42,6 +50,20 @@ async function patchAppConfigurationFile({path, patch, schema}: PatchTomlOptions
   await writeFile(path, encodedString)
 }
 
+async function patchAppConfigurationFileWithWasm(
+  path: string,
+  configValues: {keyPath: string; value: TomlPatchValue}[],
+) {
+  // Don't import WASM unless we are really using it.
+  const {updateTomlValues} = await import('./toml-patch-wasm.js')
+  const tomlContents = await readFile(path)
+  const updatedConfig = await updateTomlValues(
+    tomlContents,
+    configValues.map(({keyPath, value}) => [keyPath.split('.'), value]),
+  )
+  await writeFile(path, updatedConfig)
+}
+
 /**
  * Sets a single value in the app configuration file based on a dotted key path.
  *
@@ -50,7 +72,15 @@ async function patchAppConfigurationFile({path, patch, schema}: PatchTomlOptions
  * @param value - The value to set
  * @param schema - The schema to validate the patch against. If not provided, the toml will not be validated.
  */
-export async function setAppConfigValue(path: string, keyPath: string, value: unknown, schema?: zod.AnyZodObject) {
+export async function setAppConfigValue(
+  path: string,
+  keyPath: string,
+  value: TomlPatchValue,
+  schema?: zod.AnyZodObject,
+) {
+  if (shouldUseWasmTomlPatch()) {
+    return patchAppConfigurationFileWithWasm(path, [{keyPath, value}])
+  }
   const patch = createPatchFromDottedPath(keyPath, value)
   await patchAppConfigurationFile({path, patch, schema})
 }
@@ -72,9 +102,12 @@ export async function setAppConfigValue(path: string, keyPath: string, value: un
  */
 export async function setManyAppConfigValues(
   path: string,
-  configValues: {keyPath: string; value: unknown}[],
+  configValues: {keyPath: string; value: TomlPatchValue}[],
   schema?: zod.AnyZodObject,
 ) {
+  if (shouldUseWasmTomlPatch()) {
+    return patchAppConfigurationFileWithWasm(path, configValues)
+  }
   const patch = configValues.reduce((acc, {keyPath, value}) => {
     const valuePatch = createPatchFromDottedPath(keyPath, value)
     return deepMergeObjects(acc, valuePatch, replaceArrayStrategy)
@@ -91,6 +124,9 @@ export async function setManyAppConfigValues(
  * @param schema - The schema to validate the patch against. If not provided, the toml will not be validated.
  */
 export async function unsetAppConfigValue(path: string, keyPath: string, schema?: zod.AnyZodObject) {
+  if (shouldUseWasmTomlPatch()) {
+    return patchAppConfigurationFileWithWasm(path, [{keyPath, value: undefined}])
+  }
   const patch = createPatchFromDottedPath(keyPath, undefined)
   await patchAppConfigurationFile({path, patch, schema})
 }
diff --git a/packages/app/src/cli/services/app/toml-patch-wasm.test.ts b/packages/app/src/cli/services/app/toml-patch-wasm.test.ts
new file mode 100644
index 00000000000..75056abfd10
--- /dev/null
+++ b/packages/app/src/cli/services/app/toml-patch-wasm.test.ts
@@ -0,0 +1,50 @@
+import {updateTomlValues} from './toml-patch-wasm.js'
+import {describe, expect, test} from 'vitest'
+
+// Sample TOML content for testing
+const sampleToml = `
+# This is a sample TOML file
+title = "TOML Example"
+
+[owner]
+name = "Test User"
+organization = "Test Org"
+
+[database]
+server = "192.168.1.1"
+ports = [ 8001, 8001, 8002 ] # Comment after array
+enabled = true
+`
+
+describe('WASM TOML Patch Integration', () => {
+  test('updating TOML makes minimal changes and preserves as much as possible', async () => {
+    const output = await updateTomlValues(sampleToml, [
+      [['owner', 'dotted', 'notation'], 123.5],
+      [['database', 'server'], 'changed'],
+      [['top_level'], true],
+      [['owner', 'organization'], undefined],
+      [
+        ['database', 'backup_ports'],
+        [8003, 8004],
+      ],
+    ])
+
+    const expected = `
+# This is a sample TOML file
+title = "TOML Example"
+top_level = true
+
+[owner]
+name = "Test User"
+dotted.notation = 123.5
+
+[database]
+server = "changed"
+ports = [ 8001, 8001, 8002 ] # Comment after array
+enabled = true
+backup_ports = [8003, 8004]
+`
+
+    expect(output).toBe(expected)
+  })
+})
diff --git a/packages/app/src/cli/services/app/toml-patch-wasm.ts b/packages/app/src/cli/services/app/toml-patch-wasm.ts
new file mode 100644
index 00000000000..001e8c338ad
--- /dev/null
+++ b/packages/app/src/cli/services/app/toml-patch-wasm.ts
@@ -0,0 +1,15 @@
+import * as tomlPatch from '@shopify/toml-patch'
+
+type TomlSingleValue = string | number | boolean
+
+type TomlPatchValue = TomlSingleValue | TomlSingleValue[] | undefined
+
+/**
+ * Update the TOML content using the WASM module
+ * @param tomlContent - The TOML content to update
+ * @param patches - An array of tuples, each containing a dotted path and a value
+ * @returns The updated TOML content
+ */
+export async function updateTomlValues(tomlContent: string, patches: [string[], TomlPatchValue][]): Promise<string> {
+  return tomlPatch.updateTomlValues(tomlContent, patches)
+}
diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js
index b740f1eaad5..4e6ff6de3bf 100644
--- a/packages/cli/bin/bundle.js
+++ b/packages/cli/bin/bundle.js
@@ -24,6 +24,8 @@ const external = [
 // yoga wasm file is not bundled by esbuild, so we need to copy it manually
 const yogafile = glob.sync('../../node_modules/.pnpm/**/yoga.wasm')[0]
 
+const wasmTomlPatchFile = glob.sync('../../node_modules/.pnpm/**/toml_patch_bg.wasm')[0]
+
 // Find theme-check-node's config yml files
 const themePath = require.resolve('@shopify/theme-check-node')
 const configYmlPath = joinPath(themePath, '..', '..', 'configs/*.yml')
@@ -91,6 +93,10 @@ esBuild({
           from: [yogafile],
           to: ['./dist/'],
         },
+        {
+          from: [wasmTomlPatchFile],
+          to: ['./dist/'],
+        },
         {
           from: [configYmlPath],
           to: ['./dist/configs/'],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6b249415fc..e8aebe85e82 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -169,6 +169,9 @@ importers:
       '@shopify/theme-check-node':
         specifier: 3.15.1
         version: 3.15.1
+      '@shopify/toml-patch':
+        specifier: 0.3.0
+        version: 0.3.0
       body-parser:
         specifier: 1.20.3
         version: 1.20.3
@@ -6632,6 +6635,10 @@ packages:
       - encoding
     dev: false
 
+  /@shopify/toml-patch@0.3.0:
+    resolution: {integrity: sha512-ruv2FT17FW3CfWx8jXEyfx3xZDdo22PDaFQ9R7QYOovXQbgQCIKY+5QjGVBA7jsyLm+Wa2ngVANmt7MS0M/g+w==}
+    dev: false
+
   /@shopify/useful-types@4.0.3:
     resolution: {integrity: sha512-vT48uKFSTfI3HPqtNI3lahWRDncFuuzBVfj2eUxEpQO//3Sn+IIo4zRPT9W4EHBiVWJyBeW5CTzVtvGhQma4EA==}
     engines: {node: '>=12.14.0'}


Reviews:
user: login: isaacroldan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:40:23Z
user: login: shauns
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:21:22Z
user: login: isaacroldan
body: 🎩 'ed and works 👌 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:01:00Z

Review comments:
diff_hunk: @@ -72,9 +102,12 @@ export async function setAppConfigValue(path: string, keyPath: string, value: un
  */
 export async function setManyAppConfigValues(
   path: string,
-  configValues: {keyPath: string; value: unknown}[],
+  configValues: {keyPath: string; value: TomlPatchValue}[],
   schema?: zod.AnyZodObject,
 ) {
+  if (shouldUseWasmTomlPatch()) {
+    return patchAppConfigurationFileWithWasm(path, configValues)
+  }
   const patch = configValues.reduce((acc, {keyPath, value}) => {
     const valuePatch = createPatchFromDottedPath(keyPath, value)
     return deepMergeObjects(acc, valuePatch, replaceArrayStrategy)
path: packages/app/src/cli/services/app/patch-app-configuration-file.ts
user: login: isaacroldan
body: <!--__GRAPHITE_HTML_TAG_START__--><p class='graphite__hidden'><i>[Re: line +127]</i></p><!--__GRAPHITE_HTML_TAG_END__-->
don't we need to use the wasm version for the `unset` case?
<!--__GRAPHITE_HTML_TAG_START__--><p class='graphite__hidden'>See this comment inline on <a href="https://app.graphite.dev/github/pr/Shopify/cli/5677?utm_source=unchanged-line-comment">Graphite</a>.</p><!--__GRAPHITE_HTML_TAG_END__-->
created_at: 2025-04-23T15:40:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,9 +102,12 @@ export async function setAppConfigValue(path: string, keyPath: string, value: un
  */
 export async function setManyAppConfigValues(
   path: string,
-  configValues: {keyPath: string; value: unknown}[],
+  configValues: {keyPath: string; value: TomlPatchValue}[],
   schema?: zod.AnyZodObject,
 ) {
+  if (shouldUseWasmTomlPatch()) {
+    return patchAppConfigurationFileWithWasm(path, configValues)
+  }
   const patch = configValues.reduce((acc, {keyPath, value}) => {
     const valuePatch = createPatchFromDottedPath(keyPath, value)
     return deepMergeObjects(acc, valuePatch, replaceArrayStrategy)
path: packages/app/src/cli/services/app/patch-app-configuration-file.ts
user: login: shauns
body: missed it, thanks!
created_at: 2025-04-26T13:21:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: shauns
created_at: 2025-04-22T13:42:31Z
body: * **#5678** <a href="https://app.graphite.dev/github/pr/Shopify/cli/5678?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#5677** <a href="https://app.graphite.dev/github/pr/Shopify/cli/5677?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/Shopify/cli/5677?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#5641** <a href="https://app.graphite.dev/github/pr/Shopify/cli/5641?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#5642](https://github.com/Shopify/cli/pull/5642) <a href="https://app.graphite.dev/github/pr/Shopify/cli/5642?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#5685** <a href="https://app.graphite.dev/github/pr/Shopify/cli/5685?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-22T13:44:32Z
body: ## Differences in type declarations
We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to **ensure they are backward-compatible.** Here are some important things to keep in mind:
- Some seemingly private modules might be re-exported through public modules.
- If the branch is behind `main` you might see odd diffs, rebase `main` into this branch.

### New type declarations
We found no new type declarations in this PR

### Existing type declarations
<details>
<summary>
packages/cli-kit/dist/public/common/object.d.ts
</summary>

```diff
@@ -1,4 +1,4 @@
-import { Dictionary, ObjectIterator, PropertyPath, ValueKeyIteratee } from 'lodash';
+import { Dictionary, ObjectIterator, ValueKeyIteratee } from 'lodash';
 /**
  * Deep merges the two objects and returns a new object with the merge result.
  *
@@ -53,7 +53,7 @@ export declare function deepDifference(one: object, two: object): [object, objec
  * @param path - The path of the property to get.
  * @returns - Returns the resolved value.
  */
-export declare function getPathValue<T = object>(object: object, path: PropertyPath): T | undefined;
+export declare function getPathValue<T = object>(object: object, path: string): T | undefined;
 /**
  * Sets the value at path of object. If a portion of path doesn't exist, it's create.
  *
@@ -62,7 +62,7 @@ export declare function getPathValue<T = object>(object: object, path: PropertyP
  * @param value - The value to set.
  * @returns - Returns object.
  */
-export declare function setPathValue(object: object, path: PropertyPath, value?: unknown): object;
+export declare function setPathValue(object: object, path: string, value?: unknown): object;
 /**
  * Removes the property at path of object.
  *
@@ -70,7 +70,7 @@ export declare function setPathValue(object: object, path: PropertyPath, value?:
  * @param path - The path of the property to unset.
  * @returns - Returns true if the property is deleted or not found, else false.
  */
-export declare function unsetPathValue(object: object, path: PropertyPath): boolean;
+export declare function unsetPathValue(object: object, path: string): boolean;
 /**
  * Checks if value is an empty object, collection, map, or set.
  *

```

</details>
<!-- Sticky Pull Request CommentType-diff -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-22T13:45:00Z
body: We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run "pnpm changeset add" to track your changes and include them in the next release CHANGELOG.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-22T13:46:44Z
body: <!-- jest coverage report action for options with hash 4c542401e6e51275fe98fdb0130d9f77 -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage                                                     | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :------------------------------------------------------------- | :-------------: |
|                                                                           🟡                                                                           | Statements | <div title="Base coverage is: 76.69%">76.73% (+0.04% 🔼)</div> |    9575/12479   |
|                                                                           🟡                                                                           | Branches   | <div title="Base coverage is: 71.77%">71.83% (+0.06% 🔼)</div> |    4700/6543    |
|                                                                           🟡                                                                           | Functions  | <div title="Base coverage is: 76.46%">76.49% (+0.03% 🔼)</div> |    2479/3241    |
|                                                                           🟡                                                                           | Lines      | <div title="Base coverage is: 77.21%">77.26% (+0.04% 🔼)</div> |    9052/11717   |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                                      | Statements | Branches | Functions | Lines |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------- | :--------- | :------- | :-------- | :---- |
|                                                                           🟢                                                                           | <div title="app/src/cli/services/app/toml-patch-wasm.ts">`...` / toml-patch-wasm.ts</div> | 100%       | 100%     | 100%      | 100%  |

</details>


## Test suite run success
2246 tests passing in 979 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 58390558cea5e0ca0c6448acb3cf2747b03ee9ed</p>
<!-- Sticky Pull Request Comment -->
author_association: CONTRIBUTOR
user: login: shauns
created_at: 2025-04-22T13:52:14Z
body: /snapit
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-22T13:54:28Z
body: 🫰✨ **Thanks @shauns! Your snapshot has been published to npm.**

Test the snapshot by installing your package globally:
```bash
pnpm i -g @shopify/cli@0.0.0-snapshot-20250422135244
```

> [!TIP]
> If you get an `ETARGET` error, install it with NPM and the flag `--@shopify:registry=https://registry.npmjs.org`

> [!CAUTION]
> After installing, validate the version by running just `shopify` in your terminal.
> If the versions don't match, you might have multiple global instances installed.
> Use `which shopify` to find out which one you are running and uninstall it.
author_association: CONTRIBUTOR
user: login: shauns
created_at: 2025-04-23T08:20:45Z
body: /snapit
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T08:22:38Z
body: 🫰✨ **Thanks @shauns! Your snapshot has been published to npm.**

Test the snapshot by installing your package globally:
```bash
pnpm i -g @shopify/cli@0.0.0-snapshot-20250423082124
```

> [!TIP]
> If you get an `ETARGET` error, install it with NPM and the flag `--@shopify:registry=https://registry.npmjs.org`

> [!CAUTION]
> After installing, validate the version by running just `shopify` in your terminal.
> If the versions don't match, you might have multiple global instances installed.
> Use `which shopify` to find out which one you are running and uninstall it.
author_association: CONTRIBUTOR