diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 462af313375..218415295aa 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -24,6 +24,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./node/toml": { + "node": "./dist/public/node/toml/index.js", + "types": "./dist/public/node/toml/index.d.ts" + }, "./*": { "node": "./dist/public/*.js", "types": "./dist/public/*.d.ts" @@ -104,6 +108,7 @@ "@graphql-typed-document-node/core": "3.2.0", "@iarna/toml": "2.2.5", "@oclif/core": "4.5.3", + "@shopify/toml-patch": "0.3.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/core": "1.30.0", "@opentelemetry/exporter-metrics-otlp-http": "0.57.0", diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index 63c0a0fd19a..30dfe245fed 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -1,6 +1,6 @@ import Command from './base-command.js' import {Environments} from './environments.js' -import {encodeToml as encodeTOML} from './toml.js' +import {encodeToml as encodeTOML} from './toml/codec.js' import {globalFlags} from './cli.js' import {inTemporaryDirectory, mkdir, writeFile} from './fs.js' import {joinPath, resolvePath, cwd} from './path.js' diff --git a/packages/cli-kit/src/public/node/environments.test.ts b/packages/cli-kit/src/public/node/environments.test.ts index 888ae04d58a..e646a8a80ca 100644 --- a/packages/cli-kit/src/public/node/environments.test.ts +++ b/packages/cli-kit/src/public/node/environments.test.ts @@ -1,5 +1,5 @@ import * as environments from './environments.js' -import {encodeToml as tomlEncode} from './toml.js' +import {encodeToml as tomlEncode} from './toml/codec.js' import {inTemporaryDirectory, writeFile} from './fs.js' import {joinPath} from './path.js' import {mockAndCaptureOutput} from './testing/output.js' diff --git a/packages/cli-kit/src/public/node/environments.ts b/packages/cli-kit/src/public/node/environments.ts index cdbf3c8075d..cf51af031dd 100644 --- a/packages/cli-kit/src/public/node/environments.ts +++ b/packages/cli-kit/src/public/node/environments.ts @@ -1,4 +1,4 @@ -import {decodeToml} from './toml.js' +import {decodeToml} from './toml/codec.js' import {findPathUp, readFile} from './fs.js' import {cwd} from './path.js' import * as metadata from './metadata.js' diff --git a/packages/cli-kit/src/public/node/json-schema.test.ts b/packages/cli-kit/src/public/node/json-schema.test.ts index a7202625675..46583d30050 100644 --- a/packages/cli-kit/src/public/node/json-schema.test.ts +++ b/packages/cli-kit/src/public/node/json-schema.test.ts @@ -1,5 +1,5 @@ import {jsonSchemaValidate, normaliseJsonSchema} from './json-schema.js' -import {decodeToml} from './toml.js' +import {decodeToml} from './toml/codec.js' import {zod} from './schema.js' import {describe, expect, test} from 'vitest' diff --git a/packages/cli-kit/src/public/node/toml.test.ts b/packages/cli-kit/src/public/node/toml/codec.test.ts similarity index 95% rename from packages/cli-kit/src/public/node/toml.test.ts rename to packages/cli-kit/src/public/node/toml/codec.test.ts index 545fd28fe06..bbadfd7b30b 100644 --- a/packages/cli-kit/src/public/node/toml.test.ts +++ b/packages/cli-kit/src/public/node/toml/codec.test.ts @@ -1,4 +1,4 @@ -import {decodeToml} from './toml.js' +import {decodeToml} from './codec.js' import {describe, expect, test} from 'vitest' describe('decodeToml', () => { diff --git a/packages/cli-kit/src/public/node/toml.ts b/packages/cli-kit/src/public/node/toml/codec.ts similarity index 92% rename from packages/cli-kit/src/public/node/toml.ts rename to packages/cli-kit/src/public/node/toml/codec.ts index ecc00e9204f..5fd5460807e 100644 --- a/packages/cli-kit/src/public/node/toml.ts +++ b/packages/cli-kit/src/public/node/toml/codec.ts @@ -1,4 +1,4 @@ -import {JsonMap} from '../../private/common/json.js' +import {JsonMap} from '../../../private/common/json.js' import * as toml from '@iarna/toml' export type JsonMapType = JsonMap diff --git a/packages/cli-kit/src/public/node/toml/index.ts b/packages/cli-kit/src/public/node/toml/index.ts new file mode 100644 index 00000000000..57b732c6d8a --- /dev/null +++ b/packages/cli-kit/src/public/node/toml/index.ts @@ -0,0 +1,2 @@ +export {decodeToml, encodeToml} from './codec.js' +export type {JsonMapType} from './codec.js' diff --git a/packages/cli-kit/src/public/node/toml/toml-file.test.ts b/packages/cli-kit/src/public/node/toml/toml-file.test.ts new file mode 100644 index 00000000000..10b361203ed --- /dev/null +++ b/packages/cli-kit/src/public/node/toml/toml-file.test.ts @@ -0,0 +1,299 @@ +import {TomlFile, TomlParseError} from './toml-file.js' +import {writeFile, readFile, inTemporaryDirectory} from '../fs.js' +import {joinPath} from '../path.js' +import {describe, expect, test} from 'vitest' + +describe('TomlFile', () => { + describe('read', () => { + test('reads and parses a TOML file', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "my-app"\nclient_id = "123"\n') + + const file = await TomlFile.read(path) + + expect(file.path).toBe(path) + expect(file.content).toStrictEqual({name: 'my-app', client_id: '123'}) + }) + }) + + test('reads nested tables', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '[build]\ndev_store_url = "my-store.myshopify.com"\n') + + const file = await TomlFile.read(path) + + expect(file.content).toStrictEqual({build: {dev_store_url: 'my-store.myshopify.com'}}) + }) + }) + + test('throws TomlParseError with file path on invalid TOML', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'bad.toml') + await writeFile(path, 'name = [invalid') + + await expect(TomlFile.read(path)).rejects.toThrow(TomlParseError) + await expect(TomlFile.read(path)).rejects.toThrow(/bad\.toml/) + }) + }) + + test('throws if file does not exist', async () => { + await expect(TomlFile.read('/nonexistent/path/test.toml')).rejects.toThrow() + }) + }) + + describe('patch', () => { + test('sets a top-level value', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "old"\n') + + const file = await TomlFile.read(path) + await file.patch({name: 'new'}) + + expect(file.content.name).toBe('new') + const raw = await readFile(path) + expect(raw).toContain('name = "new"') + }) + }) + + test('sets a nested value', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '[build]\ndev_store_url = "old.myshopify.com"\n') + + const file = await TomlFile.read(path) + await file.patch({build: {dev_store_url: 'new.myshopify.com'}}) + + expect(file.content).toStrictEqual({build: {dev_store_url: 'new.myshopify.com'}}) + }) + }) + + test('creates intermediate tables', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\n') + + const file = await TomlFile.read(path) + await file.patch({build: {dev_store_url: 'store.myshopify.com'}}) + + expect(file.content).toStrictEqual({ + name: 'app', + build: {dev_store_url: 'store.myshopify.com'}, + }) + }) + }) + + test('sets multiple values at once', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\nclient_id = "123"\n') + + const file = await TomlFile.read(path) + await file.patch({name: 'updated', client_id: '456'}) + + expect(file.content.name).toBe('updated') + expect(file.content.client_id).toBe('456') + }) + }) + + test('preserves comments', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '# This is a comment\nname = "app"\n') + + const file = await TomlFile.read(path) + await file.patch({name: 'updated'}) + + const raw = await readFile(path) + expect(raw).toContain('# This is a comment') + expect(raw).toContain('name = "updated"') + }) + }) + + test('handles array values', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '[auth]\nredirect_urls = ["https://old.com"]\n') + + const file = await TomlFile.read(path) + await file.patch({auth: {redirect_urls: ['https://new.com', 'https://other.com']}}) + + const content = file.content as {auth: {redirect_urls: string[]}} + expect(content.auth.redirect_urls).toStrictEqual(['https://new.com', 'https://other.com']) + }) + }) + }) + + describe('remove', () => { + test('removes a top-level key', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\nclient_id = "123"\n') + + const file = await TomlFile.read(path) + await file.remove('name') + + expect(file.content.name).toBeUndefined() + expect(file.content.client_id).toBe('123') + }) + }) + + test('removes a nested key', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile( + path, + '[build]\ndev_store_url = "store.myshopify.com"\nautomatically_update_urls_on_dev = true\n', + ) + + const file = await TomlFile.read(path) + await file.remove('build.dev_store_url') + + const build = file.content.build as {[key: string]: unknown} + expect(build.dev_store_url).toBeUndefined() + expect(build.automatically_update_urls_on_dev).toBe(true) + }) + }) + + test('preserves unrelated content', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\nclient_id = "123"\n') + + const file = await TomlFile.read(path) + await file.remove('name') + + const raw = await readFile(path) + expect(raw).toContain('client_id = "123"') + expect(raw).not.toContain('name') + }) + }) + }) + + describe('replace', () => { + test('replaces the entire file content', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "old"\n') + + const file = await TomlFile.read(path) + await file.replace({name: 'new', client_id: '789'}) + + expect(file.content).toStrictEqual({name: 'new', client_id: '789'}) + const raw = await readFile(path) + expect(raw).toContain('name = "new"') + expect(raw).toContain('client_id = "789"') + }) + }) + + test('does not preserve comments', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '# Comment\nname = "old"\n') + + const file = await TomlFile.read(path) + await file.replace({name: 'new'}) + + const raw = await readFile(path) + expect(raw).not.toContain('# Comment') + }) + }) + + test('round-trips read → replace → read', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + const original = { + name: 'my-app', + client_id: 'abc123', + build: {dev_store_url: 'store.myshopify.com'}, + } + await writeFile(path, 'name = "placeholder"\n') + + const file = await TomlFile.read(path) + await file.replace(original) + + const reread = await TomlFile.read(path) + expect(reread.content).toStrictEqual(original) + }) + }) + }) + + describe('transformRaw', () => { + test('transforms the raw TOML string and updates content', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\n') + + const file = await TomlFile.read(path) + await file.transformRaw((raw) => `# Header comment\n${raw}`) + + const raw = await readFile(path) + expect(raw).toContain('# Header comment') + expect(raw).toContain('name = "app"') + expect(file.content.name).toBe('app') + }) + }) + + test('injected comments survive subsequent patch calls', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, 'name = "app"\nclient_id = "123"\n') + + const file = await TomlFile.read(path) + await file.transformRaw((raw) => `# Keep this comment\n${raw}`) + await file.patch({name: 'updated'}) + + const raw = await readFile(path) + expect(raw).toContain('# Keep this comment') + expect(raw).toContain('name = "updated"') + }) + }) + + test('works after replace to add comments', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + await writeFile(path, '') + + const file = new TomlFile(path, {}) + await file.replace({name: 'app', client_id: '123'}) + await file.transformRaw((raw) => `# Doc link\n${raw}`) + + const raw = await readFile(path) + expect(raw).toContain('# Doc link') + expect(raw).toContain('name = "app"') + expect(file.content).toStrictEqual({name: 'app', client_id: '123'}) + }) + }) + + test('throws TomlParseError and does not write to disk when transform produces invalid TOML', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'test.toml') + const originalContent = 'name = "app"\n' + await writeFile(path, originalContent) + + const file = await TomlFile.read(path) + await expect(file.transformRaw(() => 'name = [invalid')).rejects.toThrow(TomlParseError) + + const raw = await readFile(path) + expect(raw).toBe(originalContent) + expect(file.content).toStrictEqual({name: 'app'}) + }) + }) + }) + + describe('constructor', () => { + test('creates a TomlFile instance for new files', async () => { + await inTemporaryDirectory(async (dir) => { + const path = joinPath(dir, 'new.toml') + const file = new TomlFile(path, {}) + await file.replace({type: 'ui_extension', name: 'My Extension'}) + + const raw = await readFile(path) + expect(raw).toContain('type = "ui_extension"') + expect(raw).toContain('name = "My Extension"') + }) + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts new file mode 100644 index 00000000000..f3bdd8c913b --- /dev/null +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -0,0 +1,166 @@ +import {JsonMapType, decodeToml, encodeToml} from './codec.js' +import {readFile, writeFile} from '../fs.js' +import {updateTomlValues} from '@shopify/toml-patch' + +type TomlPatchValue = string | number | boolean | undefined | (string | number | boolean)[] + +/** + * Thrown when a TOML file cannot be parsed. Includes the file path for context. + */ +export class TomlParseError extends Error { + readonly path: string + + constructor(path: string, cause: Error) { + super(`Fix the following error in ${path}:\n${cause.message}`) + this.name = 'TomlParseError' + this.path = path + } +} + +/** + * General-purpose TOML file abstraction. + * + * Provides a unified interface for reading, patching, removing keys from, and replacing + * the content of TOML files on disk. + * + * - `read` populates content from disk + * - `patch` does surgical WASM-based edits (preserves comments and formatting) + * - `remove` deletes a key by dotted path (preserves comments and formatting) + * - `replace` does a full re-serialization (comments and formatting are NOT preserved). + * - `transformRaw` applies a function to the raw TOML string on disk. + */ +export class TomlFile { + /** + * Read and parse a TOML file from disk. Throws if the file doesn't exist or contains invalid TOML. + * Parse errors are wrapped in {@link TomlParseError} with the file path for context. + * + * @param path - Absolute path to the TOML file. + * @returns A TomlFile instance with parsed content. + */ + static async read(path: string): Promise { + const raw = await readFile(path) + const file = new TomlFile(path, {}) + file.content = file.decode(raw) + return file + } + + readonly path: string + content: JsonMapType + + constructor(path: string, content: JsonMapType) { + this.path = path + this.content = content + } + + /** + * Surgically patch values in the TOML file, preserving comments and formatting. + * + * Accepts a nested object whose leaf values are set in the TOML. Intermediate tables are + * created automatically. Setting a leaf to `undefined` removes it (use `remove()` for a + * clearer API when deleting keys). + * + * @example + * ```ts + * await file.patch({build: {dev_store_url: 'my-store.myshopify.com'}}) + * await file.patch({application_url: 'https://example.com', auth: {redirect_urls: ['...']}}) + * ``` + */ + async patch(changes: {[key: string]: unknown}): Promise { + const patches = flattenToPatchEntries(changes) + const raw = await readFile(this.path) + const updated = updateTomlValues(raw, patches) + const parsed = this.decode(updated) + await writeFile(this.path, updated) + this.content = parsed + } + + /** + * Remove a key from the TOML file by dotted path, preserving comments and formatting. + * + * @param keyPath - Dotted key path to remove (e.g. 'build.include_config_on_deploy'). + * @example + * ```ts + * await file.remove('build.include_config_on_deploy') + * ``` + */ + async remove(keyPath: string): Promise { + const keys = keyPath.split('.') + const raw = await readFile(this.path) + const updated = updateTomlValues(raw, [[keys, undefined]]) + const parsed = this.decode(updated) + await writeFile(this.path, updated) + this.content = parsed + } + + /** + * Replace the entire file content. The file is fully re-serialized — comments and formatting + * are NOT preserved. + * + * @param content - The new content to write. + * @example + * ```ts + * await file.replace({client_id: 'abc', name: 'My App'}) + * ``` + */ + async replace(content: JsonMapType): Promise { + const encoded = encodeToml(content) + this.decode(encoded) + await writeFile(this.path, encoded) + this.content = content + } + + /** + * Transform the raw TOML string on disk. Reads the file, applies the transform function + * to the raw text, writes back, and re-parses to keep `content` in sync. + * + * Use this for text-level operations that can't be expressed as structured edits — + * e.g. Injecting comments or positional insertion of keys in arrays-of-tables. + * Subsequent `patch()` calls will preserve any comments added this way. + * + * @param transform - A function that receives the raw TOML string and returns the modified string. + * @example + * ```ts + * await file.transformRaw((raw) => `# Header comment\n${raw}`) + * ``` + */ + async transformRaw(transform: (raw: string) => string): Promise { + const raw = await readFile(this.path) + const transformed = transform(raw) + const parsed = this.decode(transformed) + await writeFile(this.path, transformed) + this.content = parsed + } + + private decode(raw: string): JsonMapType { + try { + return decodeToml(raw) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + if (err.line !== undefined && err.col !== undefined) { + throw new TomlParseError(this.path, err) + } + throw err + } + } +} + +/** + * Flatten a nested object into an array of `[keyPath, value]` patch entries + * suitable for `updateTomlValues`. + * + * @param obj - The nested object to flatten. + * @param prefix - Key path prefix for recursion. + * @returns Flattened patch entries. + */ +function flattenToPatchEntries(obj: {[key: string]: unknown}, prefix: string[] = []): [string[], TomlPatchValue][] { + const entries: [string[], TomlPatchValue][] = [] + for (const [key, value] of Object.entries(obj)) { + const path = [...prefix, key] + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + entries.push(...flattenToPatchEntries(value as {[key: string]: unknown}, path)) + } else { + entries.push([path, value as TomlPatchValue]) + } + } + return entries +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00707e86d61..695db8b5d11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: 1.28.0 version: 1.28.0 + '@shopify/toml-patch': + specifier: 0.3.0 + version: 0.3.0 '@types/archiver': specifier: 5.3.2 version: 5.3.2