From 12aae92c3fec3a46b1434ddef698bc61c8c28dc3 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Fri, 6 Mar 2026 09:49:00 -0700 Subject: [PATCH 1/4] Introduce TomlFile abstraction for TOML file I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a general-purpose TomlFile class in cli-kit with read/patch/remove/ replace/transformRaw methods. Restructures toml utilities into a toml/ directory with codec (internal encode/decode) and index (public JsonMapType export) modules. Only TomlFile and JsonMapType are publicly exported. No callsite migrations in this commit — purely additive. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli-kit/package.json | 5 + .../src/public/node/base-command.test.ts | 2 +- .../src/public/node/environments.test.ts | 2 +- .../cli-kit/src/public/node/environments.ts | 2 +- .../src/public/node/json-schema.test.ts | 2 +- .../node/{toml.test.ts => toml/codec.test.ts} | 2 +- .../public/node/{toml.ts => toml/codec.ts} | 2 +- .../cli-kit/src/public/node/toml/index.ts | 2 + .../src/public/node/toml/toml-file.test.ts | 284 ++++++++++++++++++ .../cli-kit/src/public/node/toml/toml-file.ts | 157 ++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 457 insertions(+), 6 deletions(-) rename packages/cli-kit/src/public/node/{toml.test.ts => toml/codec.test.ts} (95%) rename packages/cli-kit/src/public/node/{toml.ts => toml/codec.ts} (92%) create mode 100644 packages/cli-kit/src/public/node/toml/index.ts create mode 100644 packages/cli-kit/src/public/node/toml/toml-file.test.ts create mode 100644 packages/cli-kit/src/public/node/toml/toml-file.ts 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..1df1f796913 --- /dev/null +++ b/packages/cli-kit/src/public/node/toml/toml-file.test.ts @@ -0,0 +1,284 @@ +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'}) + }) + }) + }) + + 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..fb1ffffc4c1 --- /dev/null +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -0,0 +1,157 @@ +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) + try { + const content = decodeToml(raw) + return new TomlFile(path, content) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + if (err.line !== undefined && err.col !== undefined) { + throw new TomlParseError(path, err) + } + throw err + } + } + + 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) + await writeFile(this.path, updated) + this.content = decodeToml(updated) + } + + /** + * 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]]) + await writeFile(this.path, updated) + this.content = decodeToml(updated) + } + + /** + * 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) + 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) + await writeFile(this.path, transformed) + this.content = decodeToml(transformed) + } +} + +/** + * 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 From 7621b051ff891c2da4268cbd248666ff343a4134 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 9 Mar 2026 09:38:23 -0600 Subject: [PATCH 2/4] validate before write --- .../src/public/node/toml/toml-file.test.ts | 15 +++++++++++++++ .../cli-kit/src/public/node/toml/toml-file.ts | 15 ++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) 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 index 1df1f796913..10b361203ed 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.test.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.test.ts @@ -266,6 +266,21 @@ describe('TomlFile', () => { 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', () => { diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts index fb1ffffc4c1..54a87c683a6 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -39,9 +39,13 @@ export class TomlFile { */ static async read(path: string): Promise { const raw = await readFile(path) + const content = TomlFile.decode(path, raw) + return new TomlFile(path, content) + } + + private static decode(path: string, raw: string): JsonMapType { try { - const content = decodeToml(raw) - return new TomlFile(path, content) + return decodeToml(raw) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { if (err.line !== undefined && err.col !== undefined) { @@ -77,7 +81,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, patches) await writeFile(this.path, updated) - this.content = decodeToml(updated) + this.content = TomlFile.decode(this.path, updated) } /** @@ -94,7 +98,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, [[keys, undefined]]) await writeFile(this.path, updated) - this.content = decodeToml(updated) + this.content = TomlFile.decode(this.path, updated) } /** @@ -130,8 +134,9 @@ export class TomlFile { async transformRaw(transform: (raw: string) => string): Promise { const raw = await readFile(this.path) const transformed = transform(raw) + const parsed = TomlFile.decode(this.path, transformed) await writeFile(this.path, transformed) - this.content = decodeToml(transformed) + this.content = parsed } } From 5588375d24485806740f185bac95ad805b56bcbb Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 9 Mar 2026 10:39:28 -0600 Subject: [PATCH 3/4] improve decode and error functions --- .../cli-kit/src/public/node/toml/toml-file.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts index 54a87c683a6..74d6c947523 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -39,20 +39,9 @@ export class TomlFile { */ static async read(path: string): Promise { const raw = await readFile(path) - const content = TomlFile.decode(path, raw) - return new TomlFile(path, content) - } - - private static decode(path: string, 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(path, err) - } - throw err - } + const file = new TomlFile(path, {}) + file.content = file.decode(raw) + return file } readonly path: string @@ -81,7 +70,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, patches) await writeFile(this.path, updated) - this.content = TomlFile.decode(this.path, updated) + this.content = this.decode(updated) } /** @@ -98,7 +87,7 @@ export class TomlFile { const raw = await readFile(this.path) const updated = updateTomlValues(raw, [[keys, undefined]]) await writeFile(this.path, updated) - this.content = TomlFile.decode(this.path, updated) + this.content = this.decode(updated) } /** @@ -134,10 +123,22 @@ export class TomlFile { async transformRaw(transform: (raw: string) => string): Promise { const raw = await readFile(this.path) const transformed = transform(raw) - const parsed = TomlFile.decode(this.path, transformed) + 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 + } + } } /** From a4d144aadfe35a8ab33ad414b9be5f6f86dc25c4 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 9 Mar 2026 11:07:36 -0600 Subject: [PATCH 4/4] always validate toml before writing to disk --- packages/cli-kit/src/public/node/toml/toml-file.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli-kit/src/public/node/toml/toml-file.ts b/packages/cli-kit/src/public/node/toml/toml-file.ts index 74d6c947523..f3bdd8c913b 100644 --- a/packages/cli-kit/src/public/node/toml/toml-file.ts +++ b/packages/cli-kit/src/public/node/toml/toml-file.ts @@ -69,8 +69,9 @@ export class TomlFile { 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 = this.decode(updated) + this.content = parsed } /** @@ -86,8 +87,9 @@ export class TomlFile { 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 = this.decode(updated) + this.content = parsed } /** @@ -102,6 +104,7 @@ export class TomlFile { */ async replace(content: JsonMapType): Promise { const encoded = encodeToml(content) + this.decode(encoded) await writeFile(this.path, encoded) this.content = content }