Skip to content

Commit d2ffab5

Browse files
authored
Merge pull request #6943 from Shopify/rcb/toml-file-class
Introduce TomlFile abstraction for TOML file I/O
2 parents 3154508 + a4d144a commit d2ffab5

11 files changed

Lines changed: 481 additions & 6 deletions

File tree

packages/cli-kit/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"import": "./dist/index.js",
2525
"types": "./dist/index.d.ts"
2626
},
27+
"./node/toml": {
28+
"node": "./dist/public/node/toml/index.js",
29+
"types": "./dist/public/node/toml/index.d.ts"
30+
},
2731
"./*": {
2832
"node": "./dist/public/*.js",
2933
"types": "./dist/public/*.d.ts"
@@ -104,6 +108,7 @@
104108
"@graphql-typed-document-node/core": "3.2.0",
105109
"@iarna/toml": "2.2.5",
106110
"@oclif/core": "4.5.3",
111+
"@shopify/toml-patch": "0.3.0",
107112
"@opentelemetry/api": "1.9.0",
108113
"@opentelemetry/core": "1.30.0",
109114
"@opentelemetry/exporter-metrics-otlp-http": "0.57.0",

packages/cli-kit/src/public/node/base-command.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Command from './base-command.js'
22
import {Environments} from './environments.js'
3-
import {encodeToml as encodeTOML} from './toml.js'
3+
import {encodeToml as encodeTOML} from './toml/codec.js'
44
import {globalFlags} from './cli.js'
55
import {inTemporaryDirectory, mkdir, writeFile} from './fs.js'
66
import {joinPath, resolvePath, cwd} from './path.js'

packages/cli-kit/src/public/node/environments.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as environments from './environments.js'
2-
import {encodeToml as tomlEncode} from './toml.js'
2+
import {encodeToml as tomlEncode} from './toml/codec.js'
33
import {inTemporaryDirectory, writeFile} from './fs.js'
44
import {joinPath} from './path.js'
55
import {mockAndCaptureOutput} from './testing/output.js'

packages/cli-kit/src/public/node/environments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {decodeToml} from './toml.js'
1+
import {decodeToml} from './toml/codec.js'
22
import {findPathUp, readFile} from './fs.js'
33
import {cwd} from './path.js'
44
import * as metadata from './metadata.js'

packages/cli-kit/src/public/node/json-schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {jsonSchemaValidate, normaliseJsonSchema} from './json-schema.js'
2-
import {decodeToml} from './toml.js'
2+
import {decodeToml} from './toml/codec.js'
33
import {zod} from './schema.js'
44
import {describe, expect, test} from 'vitest'
55

packages/cli-kit/src/public/node/toml.test.ts renamed to packages/cli-kit/src/public/node/toml/codec.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {decodeToml} from './toml.js'
1+
import {decodeToml} from './codec.js'
22
import {describe, expect, test} from 'vitest'
33

44
describe('decodeToml', () => {

packages/cli-kit/src/public/node/toml.ts renamed to packages/cli-kit/src/public/node/toml/codec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {JsonMap} from '../../private/common/json.js'
1+
import {JsonMap} from '../../../private/common/json.js'
22
import * as toml from '@iarna/toml'
33

44
export type JsonMapType = JsonMap
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export {decodeToml, encodeToml} from './codec.js'
2+
export type {JsonMapType} from './codec.js'
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import {TomlFile, TomlParseError} from './toml-file.js'
2+
import {writeFile, readFile, inTemporaryDirectory} from '../fs.js'
3+
import {joinPath} from '../path.js'
4+
import {describe, expect, test} from 'vitest'
5+
6+
describe('TomlFile', () => {
7+
describe('read', () => {
8+
test('reads and parses a TOML file', async () => {
9+
await inTemporaryDirectory(async (dir) => {
10+
const path = joinPath(dir, 'test.toml')
11+
await writeFile(path, 'name = "my-app"\nclient_id = "123"\n')
12+
13+
const file = await TomlFile.read(path)
14+
15+
expect(file.path).toBe(path)
16+
expect(file.content).toStrictEqual({name: 'my-app', client_id: '123'})
17+
})
18+
})
19+
20+
test('reads nested tables', async () => {
21+
await inTemporaryDirectory(async (dir) => {
22+
const path = joinPath(dir, 'test.toml')
23+
await writeFile(path, '[build]\ndev_store_url = "my-store.myshopify.com"\n')
24+
25+
const file = await TomlFile.read(path)
26+
27+
expect(file.content).toStrictEqual({build: {dev_store_url: 'my-store.myshopify.com'}})
28+
})
29+
})
30+
31+
test('throws TomlParseError with file path on invalid TOML', async () => {
32+
await inTemporaryDirectory(async (dir) => {
33+
const path = joinPath(dir, 'bad.toml')
34+
await writeFile(path, 'name = [invalid')
35+
36+
await expect(TomlFile.read(path)).rejects.toThrow(TomlParseError)
37+
await expect(TomlFile.read(path)).rejects.toThrow(/bad\.toml/)
38+
})
39+
})
40+
41+
test('throws if file does not exist', async () => {
42+
await expect(TomlFile.read('/nonexistent/path/test.toml')).rejects.toThrow()
43+
})
44+
})
45+
46+
describe('patch', () => {
47+
test('sets a top-level value', async () => {
48+
await inTemporaryDirectory(async (dir) => {
49+
const path = joinPath(dir, 'test.toml')
50+
await writeFile(path, 'name = "old"\n')
51+
52+
const file = await TomlFile.read(path)
53+
await file.patch({name: 'new'})
54+
55+
expect(file.content.name).toBe('new')
56+
const raw = await readFile(path)
57+
expect(raw).toContain('name = "new"')
58+
})
59+
})
60+
61+
test('sets a nested value', async () => {
62+
await inTemporaryDirectory(async (dir) => {
63+
const path = joinPath(dir, 'test.toml')
64+
await writeFile(path, '[build]\ndev_store_url = "old.myshopify.com"\n')
65+
66+
const file = await TomlFile.read(path)
67+
await file.patch({build: {dev_store_url: 'new.myshopify.com'}})
68+
69+
expect(file.content).toStrictEqual({build: {dev_store_url: 'new.myshopify.com'}})
70+
})
71+
})
72+
73+
test('creates intermediate tables', async () => {
74+
await inTemporaryDirectory(async (dir) => {
75+
const path = joinPath(dir, 'test.toml')
76+
await writeFile(path, 'name = "app"\n')
77+
78+
const file = await TomlFile.read(path)
79+
await file.patch({build: {dev_store_url: 'store.myshopify.com'}})
80+
81+
expect(file.content).toStrictEqual({
82+
name: 'app',
83+
build: {dev_store_url: 'store.myshopify.com'},
84+
})
85+
})
86+
})
87+
88+
test('sets multiple values at once', async () => {
89+
await inTemporaryDirectory(async (dir) => {
90+
const path = joinPath(dir, 'test.toml')
91+
await writeFile(path, 'name = "app"\nclient_id = "123"\n')
92+
93+
const file = await TomlFile.read(path)
94+
await file.patch({name: 'updated', client_id: '456'})
95+
96+
expect(file.content.name).toBe('updated')
97+
expect(file.content.client_id).toBe('456')
98+
})
99+
})
100+
101+
test('preserves comments', async () => {
102+
await inTemporaryDirectory(async (dir) => {
103+
const path = joinPath(dir, 'test.toml')
104+
await writeFile(path, '# This is a comment\nname = "app"\n')
105+
106+
const file = await TomlFile.read(path)
107+
await file.patch({name: 'updated'})
108+
109+
const raw = await readFile(path)
110+
expect(raw).toContain('# This is a comment')
111+
expect(raw).toContain('name = "updated"')
112+
})
113+
})
114+
115+
test('handles array values', async () => {
116+
await inTemporaryDirectory(async (dir) => {
117+
const path = joinPath(dir, 'test.toml')
118+
await writeFile(path, '[auth]\nredirect_urls = ["https://old.com"]\n')
119+
120+
const file = await TomlFile.read(path)
121+
await file.patch({auth: {redirect_urls: ['https://new.com', 'https://other.com']}})
122+
123+
const content = file.content as {auth: {redirect_urls: string[]}}
124+
expect(content.auth.redirect_urls).toStrictEqual(['https://new.com', 'https://other.com'])
125+
})
126+
})
127+
})
128+
129+
describe('remove', () => {
130+
test('removes a top-level key', async () => {
131+
await inTemporaryDirectory(async (dir) => {
132+
const path = joinPath(dir, 'test.toml')
133+
await writeFile(path, 'name = "app"\nclient_id = "123"\n')
134+
135+
const file = await TomlFile.read(path)
136+
await file.remove('name')
137+
138+
expect(file.content.name).toBeUndefined()
139+
expect(file.content.client_id).toBe('123')
140+
})
141+
})
142+
143+
test('removes a nested key', async () => {
144+
await inTemporaryDirectory(async (dir) => {
145+
const path = joinPath(dir, 'test.toml')
146+
await writeFile(
147+
path,
148+
'[build]\ndev_store_url = "store.myshopify.com"\nautomatically_update_urls_on_dev = true\n',
149+
)
150+
151+
const file = await TomlFile.read(path)
152+
await file.remove('build.dev_store_url')
153+
154+
const build = file.content.build as {[key: string]: unknown}
155+
expect(build.dev_store_url).toBeUndefined()
156+
expect(build.automatically_update_urls_on_dev).toBe(true)
157+
})
158+
})
159+
160+
test('preserves unrelated content', async () => {
161+
await inTemporaryDirectory(async (dir) => {
162+
const path = joinPath(dir, 'test.toml')
163+
await writeFile(path, 'name = "app"\nclient_id = "123"\n')
164+
165+
const file = await TomlFile.read(path)
166+
await file.remove('name')
167+
168+
const raw = await readFile(path)
169+
expect(raw).toContain('client_id = "123"')
170+
expect(raw).not.toContain('name')
171+
})
172+
})
173+
})
174+
175+
describe('replace', () => {
176+
test('replaces the entire file content', async () => {
177+
await inTemporaryDirectory(async (dir) => {
178+
const path = joinPath(dir, 'test.toml')
179+
await writeFile(path, 'name = "old"\n')
180+
181+
const file = await TomlFile.read(path)
182+
await file.replace({name: 'new', client_id: '789'})
183+
184+
expect(file.content).toStrictEqual({name: 'new', client_id: '789'})
185+
const raw = await readFile(path)
186+
expect(raw).toContain('name = "new"')
187+
expect(raw).toContain('client_id = "789"')
188+
})
189+
})
190+
191+
test('does not preserve comments', async () => {
192+
await inTemporaryDirectory(async (dir) => {
193+
const path = joinPath(dir, 'test.toml')
194+
await writeFile(path, '# Comment\nname = "old"\n')
195+
196+
const file = await TomlFile.read(path)
197+
await file.replace({name: 'new'})
198+
199+
const raw = await readFile(path)
200+
expect(raw).not.toContain('# Comment')
201+
})
202+
})
203+
204+
test('round-trips read → replace → read', async () => {
205+
await inTemporaryDirectory(async (dir) => {
206+
const path = joinPath(dir, 'test.toml')
207+
const original = {
208+
name: 'my-app',
209+
client_id: 'abc123',
210+
build: {dev_store_url: 'store.myshopify.com'},
211+
}
212+
await writeFile(path, 'name = "placeholder"\n')
213+
214+
const file = await TomlFile.read(path)
215+
await file.replace(original)
216+
217+
const reread = await TomlFile.read(path)
218+
expect(reread.content).toStrictEqual(original)
219+
})
220+
})
221+
})
222+
223+
describe('transformRaw', () => {
224+
test('transforms the raw TOML string and updates content', async () => {
225+
await inTemporaryDirectory(async (dir) => {
226+
const path = joinPath(dir, 'test.toml')
227+
await writeFile(path, 'name = "app"\n')
228+
229+
const file = await TomlFile.read(path)
230+
await file.transformRaw((raw) => `# Header comment\n${raw}`)
231+
232+
const raw = await readFile(path)
233+
expect(raw).toContain('# Header comment')
234+
expect(raw).toContain('name = "app"')
235+
expect(file.content.name).toBe('app')
236+
})
237+
})
238+
239+
test('injected comments survive subsequent patch calls', async () => {
240+
await inTemporaryDirectory(async (dir) => {
241+
const path = joinPath(dir, 'test.toml')
242+
await writeFile(path, 'name = "app"\nclient_id = "123"\n')
243+
244+
const file = await TomlFile.read(path)
245+
await file.transformRaw((raw) => `# Keep this comment\n${raw}`)
246+
await file.patch({name: 'updated'})
247+
248+
const raw = await readFile(path)
249+
expect(raw).toContain('# Keep this comment')
250+
expect(raw).toContain('name = "updated"')
251+
})
252+
})
253+
254+
test('works after replace to add comments', async () => {
255+
await inTemporaryDirectory(async (dir) => {
256+
const path = joinPath(dir, 'test.toml')
257+
await writeFile(path, '')
258+
259+
const file = new TomlFile(path, {})
260+
await file.replace({name: 'app', client_id: '123'})
261+
await file.transformRaw((raw) => `# Doc link\n${raw}`)
262+
263+
const raw = await readFile(path)
264+
expect(raw).toContain('# Doc link')
265+
expect(raw).toContain('name = "app"')
266+
expect(file.content).toStrictEqual({name: 'app', client_id: '123'})
267+
})
268+
})
269+
270+
test('throws TomlParseError and does not write to disk when transform produces invalid TOML', async () => {
271+
await inTemporaryDirectory(async (dir) => {
272+
const path = joinPath(dir, 'test.toml')
273+
const originalContent = 'name = "app"\n'
274+
await writeFile(path, originalContent)
275+
276+
const file = await TomlFile.read(path)
277+
await expect(file.transformRaw(() => 'name = [invalid')).rejects.toThrow(TomlParseError)
278+
279+
const raw = await readFile(path)
280+
expect(raw).toBe(originalContent)
281+
expect(file.content).toStrictEqual({name: 'app'})
282+
})
283+
})
284+
})
285+
286+
describe('constructor', () => {
287+
test('creates a TomlFile instance for new files', async () => {
288+
await inTemporaryDirectory(async (dir) => {
289+
const path = joinPath(dir, 'new.toml')
290+
const file = new TomlFile(path, {})
291+
await file.replace({type: 'ui_extension', name: 'My Extension'})
292+
293+
const raw = await readFile(path)
294+
expect(raw).toContain('type = "ui_extension"')
295+
expect(raw).toContain('name = "My Extension"')
296+
})
297+
})
298+
})
299+
})

0 commit comments

Comments
 (0)