@highstate/cli 0.9.16 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,21 @@
1
1
  import type { Logger } from "pino"
2
+ import console from "node:console"
2
3
  import {
3
4
  type Component,
4
5
  type ComponentModel,
5
6
  type Entity,
7
+ type EntityModel,
6
8
  isComponent,
7
9
  isEntity,
8
10
  isUnitModel,
9
- originalCreate,
10
11
  } from "@highstate/contract"
11
- import { serializeFunction } from "@pulumi/pulumi/runtime/index.js"
12
12
  import { Crc32, crc32 } from "@aws-crypto/crc32"
13
13
  import { encode } from "@msgpack/msgpack"
14
- import { int32ToBytes } from "@highstate/backend/shared"
14
+ import { int32ToBytes } from "./utils"
15
15
 
16
16
  export type Library = Readonly<{
17
17
  components: Readonly<Record<string, ComponentModel>>
18
- entities: Readonly<Record<string, Entity>>
18
+ entities: Readonly<Record<string, EntityModel>>
19
19
  }>
20
20
 
21
21
  export async function loadLibrary(logger: Logger, modulePaths: string[]): Promise<Library> {
@@ -26,13 +26,15 @@ export async function loadLibrary(logger: Logger, modulePaths: string[]): Promis
26
26
  modules[modulePath] = await import(modulePath)
27
27
 
28
28
  logger.debug({ modulePath }, "module loaded")
29
- } catch (err) {
30
- logger.error({ modulePath, err }, "module load failed")
29
+ } catch (error) {
30
+ console.error(error)
31
+
32
+ throw new Error(`Failed to load module "${modulePath}"`, { cause: error })
31
33
  }
32
34
  }
33
35
 
34
36
  const components: Record<string, ComponentModel> = {}
35
- const entities: Record<string, Entity> = {}
37
+ const entities: Record<string, EntityModel> = {}
36
38
 
37
39
  await _loadLibrary(modules, components, entities)
38
40
 
@@ -52,13 +54,13 @@ export async function loadLibrary(logger: Logger, modulePaths: string[]): Promis
52
54
  async function _loadLibrary(
53
55
  value: unknown,
54
56
  components: Record<string, ComponentModel>,
55
- entities: Record<string, Entity>,
57
+ entities: Record<string, EntityModel>,
56
58
  ): Promise<void> {
57
59
  if (isComponent(value)) {
58
60
  const entityHashes: number[] = []
59
61
  for (const entity of value.entities.values()) {
60
- entity.definitionHash ??= calculateEntityDefinitionHash(entity)
61
- entityHashes.push(entity.definitionHash)
62
+ entity.model.definitionHash ??= calculateEntityDefinitionHash(entity)
63
+ entityHashes.push(entity.model.definitionHash)
62
64
  }
63
65
 
64
66
  components[value.model.type] = value.model
@@ -68,11 +70,11 @@ async function _loadLibrary(
68
70
  }
69
71
 
70
72
  if (isEntity(value)) {
71
- entities[value.type] = value
73
+ entities[value.type] = value.model
72
74
  entities[value.type].definitionHash ??= calculateEntityDefinitionHash(value)
73
75
 
74
76
  // @ts-expect-error remove the schema since it's not needed in the designer
75
- delete value.schema
77
+ delete value.model.schema
76
78
  return
77
79
  }
78
80
 
@@ -80,6 +82,19 @@ async function _loadLibrary(
80
82
  return
81
83
  }
82
84
 
85
+ if ("_zod" in value) {
86
+ // this is a zod schema, we can skip it
87
+ return
88
+ }
89
+
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) {
92
+ await _loadLibrary(item, components, entities)
93
+ }
94
+
95
+ return
96
+ }
97
+
83
98
  for (const key in value) {
84
99
  await _loadLibrary((value as Record<string, unknown>)[key], components, entities)
85
100
  }
@@ -96,7 +111,8 @@ async function calculateComponentDefinitionHash(
96
111
 
97
112
  if (!isUnitModel(component.model)) {
98
113
  // 2. for composite components, include the content of the serialized create function
99
- const serializedCreate = await serializeFunction(component[originalCreate])
114
+ // const serializedCreate = await serializeFunction(component[originalCreate])
115
+ const serializedCreate = { text: "TODO: investigate why serializeFunction hangs" }
100
116
  result.update(Buffer.from(serializedCreate.text))
101
117
  }
102
118
 
@@ -109,5 +125,5 @@ async function calculateComponentDefinitionHash(
109
125
  }
110
126
 
111
127
  function calculateEntityDefinitionHash(entity: Entity): number {
112
- return crc32(encode(entity))
128
+ return crc32(encode(entity.model))
113
129
  }
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"
2
2
  import { applySchemaTransformations } from "./schema-transformer"
3
3
 
4
4
  describe("applySchemaTransformations", () => {
5
- it("should transform simple values to entity/schema structure", async () => {
5
+ it("should transform simple values using helper functions", async () => {
6
6
  const input = `
7
7
  const spec = {
8
8
  inputs: {
@@ -21,13 +21,16 @@ const spec = {
21
21
 
22
22
  const result = await applySchemaTransformations(input)
23
23
 
24
- expect(result).toContain("entity: clusterEntity")
25
- expect(result).toContain("schema: Type.Number()")
26
- expect(result).toContain("description: `The Kubernetes cluster to deploy on.`")
27
- expect(result).toContain("description: `The port number to use.`")
24
+ expect(result).toContain(
25
+ "$addInputDescription(clusterEntity, `The Kubernetes cluster to deploy on.`)",
26
+ )
27
+ expect(result).toContain("$addArgumentDescription(Type.Number(), `The port number to use.`)")
28
+ expect(result).toContain(
29
+ 'import { $addArgumentDescription, $addInputDescription } from "@highstate/contract"',
30
+ )
28
31
  })
29
32
 
30
- it("should inject description into existing meta field", async () => {
33
+ it("should wrap existing structured objects with helper functions", async () => {
31
34
  const input = `
32
35
  const spec = {
33
36
  args: {
@@ -46,14 +49,13 @@ const spec = {
46
49
 
47
50
  const result = await applySchemaTransformations(input)
48
51
 
52
+ expect(result).toContain("$addArgumentDescription({")
49
53
  expect(result).toContain('displayName: "API Token"')
50
54
  expect(result).toContain("sensitive: true")
51
- expect(result).toContain("description: `The API token for authentication.`")
52
- expect(result).not.toContain("...obj")
53
- expect(result).not.toContain("((obj) =>")
55
+ expect(result).toContain("}, `The API token for authentication.`)")
54
56
  })
55
57
 
56
- it("should add meta field if it doesn't exist in structured object", async () => {
58
+ it("should wrap structured objects with helper functions", async () => {
57
59
  const input = `
58
60
  const spec = {
59
61
  inputs: {
@@ -69,10 +71,10 @@ const spec = {
69
71
 
70
72
  const result = await applySchemaTransformations(input)
71
73
 
74
+ expect(result).toContain("$addInputDescription({")
72
75
  expect(result).toContain("entity: endpointEntity")
73
76
  expect(result).toContain("required: false")
74
- expect(result).toContain("meta: {")
75
- expect(result).toContain("description: `The target endpoint.`")
77
+ expect(result).toContain("}, `The target endpoint.`)")
76
78
  })
77
79
 
78
80
  it("should handle $args marker function", async () => {
@@ -88,8 +90,9 @@ const spec = {
88
90
 
89
91
  const result = await applySchemaTransformations(input)
90
92
 
91
- expect(result).toContain("schema: Type.String()")
92
- expect(result).toContain("description: `The configuration file path.`")
93
+ expect(result).toContain(
94
+ "$addArgumentDescription(Type.String(), `The configuration file path.`)",
95
+ )
93
96
  })
94
97
 
95
98
  it("should handle $inputs marker function", async () => {
@@ -105,8 +108,7 @@ const spec = {
105
108
 
106
109
  const result = await applySchemaTransformations(input)
107
110
 
108
- expect(result).toContain("entity: dataEntity")
109
- expect(result).toContain("description: `The source data.`")
111
+ expect(result).toContain("$addInputDescription(dataEntity, `The source data.`)")
110
112
  })
111
113
 
112
114
  it("should handle $outputs marker function", async () => {
@@ -122,8 +124,7 @@ const spec = {
122
124
 
123
125
  const result = await applySchemaTransformations(input)
124
126
 
125
- expect(result).toContain("entity: resultEntity")
126
- expect(result).toContain("description: `The processed result.`")
127
+ expect(result).toContain("$addInputDescription(resultEntity, `The processed result.`)")
127
128
  })
128
129
 
129
130
  it("should handle $secrets marker function", async () => {
@@ -139,8 +140,7 @@ const spec = {
139
140
 
140
141
  const result = await applySchemaTransformations(input)
141
142
 
142
- expect(result).toContain("schema: Type.String()")
143
- expect(result).toContain("description: `The database password.`")
143
+ expect(result).toContain("$addArgumentDescription(Type.String(), `The database password.`)")
144
144
  })
145
145
 
146
146
  it("should ignore properties without JSDoc comments", async () => {
@@ -158,8 +158,7 @@ const spec = {
158
158
  const result = await applySchemaTransformations(input)
159
159
 
160
160
  expect(result).toContain("cluster: clusterEntity") // unchanged
161
- expect(result).toContain("entity: endpointEntity") // transformed
162
- expect(result).toContain("description: `Only this one has a comment.`")
161
+ expect(result).toContain("$addInputDescription(endpointEntity, `Only this one has a comment.`)") // transformed
163
162
  })
164
163
 
165
164
  it("should ignore properties not in target objects", async () => {
@@ -183,7 +182,7 @@ const spec = {
183
182
  const result = await applySchemaTransformations(input)
184
183
 
185
184
  expect(result).toContain('someProperty: "value"') // unchanged
186
- expect(result).toContain("entity: clusterEntity") // transformed
185
+ expect(result).toContain("$addInputDescription(clusterEntity, `This should be transformed.`)") // transformed
187
186
  })
188
187
 
189
188
  it("should clean JSDoc comments properly", async () => {
@@ -200,10 +199,434 @@ const spec = {
200
199
 
201
200
  const result = await applySchemaTransformations(input)
202
201
 
203
- expect(result).toContain("schema: Type.String()")
204
202
  expect(result).toContain(
205
- "description: `This is a description with \\`backticks\\` and \\${template} literals.",
203
+ "$addArgumentDescription(Type.String(), `This is a description with \\`backticks\\` and \\${template} literals.",
204
+ )
205
+ expect(result).toContain("It also has multiple lines.`)")
206
+ })
207
+
208
+ it("should add .meta() to z.object fields with JSDoc comments", async () => {
209
+ const input = `
210
+ const userSchema = z.object({
211
+ /**
212
+ * The user's unique identifier.
213
+ */
214
+ id: z.string(),
215
+ /**
216
+ * The user's email address.
217
+ */
218
+ email: z.string().email(),
219
+ name: z.string(), // no comment, should not be transformed
220
+ })`
221
+
222
+ const result = await applySchemaTransformations(input)
223
+
224
+ // Should add import at the top
225
+ expect(result).toContain(
226
+ 'import { camelCaseToHumanReadable as __camelCaseToHumanReadable } from "@highstate/contract"',
227
+ )
228
+
229
+ // Should add .meta() with both title and description
230
+ expect(result).toContain(
231
+ 'id: z.string().meta({ title: __camelCaseToHumanReadable("id"), description: `The user\'s unique identifier.` })',
232
+ )
233
+ expect(result).toContain(
234
+ 'email: z.string().email().meta({ title: __camelCaseToHumanReadable("email"), description: `The user\'s email address.` })',
235
+ )
236
+ expect(result).toContain("name: z.string(), // no comment") // unchanged
237
+ })
238
+
239
+ it("should not add .meta() if already present in z.object fields", async () => {
240
+ const input = `
241
+ const userSchema = z.object({
242
+ /**
243
+ * The user's identifier.
244
+ */
245
+ id: z.string().meta({ displayName: "ID" }),
246
+ })`
247
+
248
+ const result = await applySchemaTransformations(input)
249
+
250
+ // Should not modify the field that already has .meta()
251
+ expect(result).toContain('id: z.string().meta({ displayName: "ID" })')
252
+ expect(result).not.toContain("description:")
253
+ })
254
+
255
+ it("should handle nested z.object patterns", async () => {
256
+ const input = `
257
+ const schema = z.object({
258
+ user: z.object({
259
+ /**
260
+ * The user's name.
261
+ */
262
+ name: z.string(),
263
+ profile: z.object({
264
+ /**
265
+ * The user's age.
266
+ */
267
+ age: z.number(),
268
+ }),
269
+ }),
270
+ })`
271
+
272
+ const result = await applySchemaTransformations(input)
273
+
274
+ expect(result).toContain(
275
+ 'name: z.string().meta({ title: __camelCaseToHumanReadable("name"), description: `The user\'s name.` })',
206
276
  )
207
- expect(result).toContain("It also has multiple lines.`")
277
+ expect(result).toContain(
278
+ 'age: z.number().meta({ title: __camelCaseToHumanReadable("age"), description: `The user\'s age.` })',
279
+ )
280
+ })
281
+
282
+ it("should handle multiple JSDoc-commented fields in the same z.object without conflicts", async () => {
283
+ const input = `
284
+ const networkSchema = z.discriminatedUnion("type", [
285
+ z.object({
286
+ type: z.literal("dhcp"),
287
+ }),
288
+ z.object({
289
+ type: z.literal("static"),
290
+
291
+ /**
292
+ * The IPv4 address to assign to the virtual machine.
293
+ */
294
+ address: z.string().optional(),
295
+
296
+ /**
297
+ * The CIDR prefix for the IPv4 address.
298
+ *
299
+ * By default, this is set to 24.
300
+ */
301
+ prefix: z.number().default(24),
302
+
303
+ /**
304
+ * The IPv4 gateway for the virtual machine.
305
+ *
306
+ * If not specified, will be set to the first address in the subnet.
307
+ */
308
+ gateway: z.string().optional(),
309
+ }),
310
+ ]).default({ type: "dhcp" })`
311
+
312
+ const result = await applySchemaTransformations(input)
313
+
314
+ expect(result).toContain(
315
+ 'address: z.string().optional().meta({ title: __camelCaseToHumanReadable("address"), description: `The IPv4 address to assign to the virtual machine.` })',
316
+ )
317
+ expect(result).toContain(
318
+ 'prefix: z.number().default(24).meta({ title: __camelCaseToHumanReadable("prefix"), description: `The CIDR prefix for the IPv4 address.',
319
+ )
320
+ expect(result).toContain("By default, this is set to 24.` })")
321
+ expect(result).toContain(
322
+ 'gateway: z.string().optional().meta({ title: __camelCaseToHumanReadable("gateway"), description: `The IPv4 gateway for the virtual machine.',
323
+ )
324
+ expect(result).toContain(
325
+ "If not specified, will be set to the first address in the subnet.` })",
326
+ )
327
+ expect(result).toContain('type: z.literal("static"),') // unchanged, no comment
328
+ })
329
+
330
+ it("should handle z.object fields inside z.discriminatedUnion", async () => {
331
+ const input = `
332
+ const schema = z.discriminatedUnion("type", [
333
+ z.object({
334
+ type: z.literal("dhcp"),
335
+ }),
336
+ z.object({
337
+ type: z.literal("static"),
338
+ /**
339
+ * The IPv4 address to assign to the virtual machine.
340
+ */
341
+ address: z.string().optional(),
342
+ /**
343
+ * The CIDR prefix for the IPv4 address.
344
+ */
345
+ prefix: z.number().default(24),
346
+ }),
347
+ ]).default({ type: "dhcp" })`
348
+
349
+ const result = await applySchemaTransformations(input)
350
+
351
+ expect(result).toContain(
352
+ 'address: z.string().optional().meta({ title: __camelCaseToHumanReadable("address"), description: `The IPv4 address to assign to the virtual machine.` })',
353
+ )
354
+ expect(result).toContain(
355
+ 'prefix: z.number().default(24).meta({ title: __camelCaseToHumanReadable("prefix"), description: `The CIDR prefix for the IPv4 address.` })',
356
+ )
357
+ expect(result).toContain('type: z.literal("dhcp"),') // unchanged, no comment
358
+ expect(result).toContain('type: z.literal("static"),') // unchanged, no comment
359
+ })
360
+
361
+ it("should handle the full defineUnit case with discriminated union and mixed transformations", async () => {
362
+ const input = `
363
+ var virtualMachine = defineUnit({
364
+ type: "proxmox.virtual-machine",
365
+ args: {
366
+ nodeName: z.string().optional(),
367
+ cpuType: z.string().default("host"),
368
+ cores: z.number().default(1),
369
+ sockets: z.number().default(1),
370
+ memory: z.number().default(512),
371
+ /**
372
+ * The IPv4 address configuration for the virtual machine.
373
+ */
374
+ ipv4: {
375
+ schema: z.discriminatedUnion("type", [
376
+ z.object({
377
+ type: z.literal("dhcp")
378
+ }),
379
+ z.object({
380
+ type: z.literal("static"),
381
+ /**
382
+ * The IPv4 address to assign to the virtual machine.
383
+ */
384
+ address: z.string().optional(),
385
+ /**
386
+ * The CIDR prefix for the IPv4 address.
387
+ *
388
+ * By default, this is set to 24.
389
+ */
390
+ prefix: z.number().default(24),
391
+ /**
392
+ * The IPv4 gateway for the virtual machine.
393
+ *
394
+ * If not specified, will be set to the first address in the subnet.
395
+ */
396
+ gateway: z.string().optional()
397
+ })
398
+ ]).default({ type: "dhcp" })
399
+ },
400
+ dns: z.string().array().optional(),
401
+ datastoreId: z.string().optional(),
402
+ diskSize: z.number().default(8),
403
+ bridge: z.string().default("vmbr0"),
404
+ sshPort: z.number().default(22),
405
+ sshUser: z.string().default("root"),
406
+ waitForAgent: z.boolean().default(true),
407
+ vendorData: z.string().optional()
408
+ },
409
+ secrets: {
410
+ sshPassword: z.string().optional()
411
+ },
412
+ inputs: {
413
+ proxmoxCluster: clusterEntity,
414
+ image: imageEntity,
415
+ sshKeyPair: {
416
+ entity: keyPairEntity,
417
+ required: false
418
+ },
419
+ /**
420
+ * The cloud-init vendor data to use for the virtual machine.
421
+ *
422
+ * You can provide a cloud-config from the distribution component.
423
+ */
424
+ vendorData: {
425
+ entity: fileEntity,
426
+ required: false,
427
+ }
428
+ },
429
+ outputs: serverOutputs,
430
+ meta: {
431
+ title: "Proxmox Virtual Machine",
432
+ description: "The virtual machine on a Proxmox cluster.",
433
+ category: "Proxmox",
434
+ icon: "simple-icons:proxmox",
435
+ iconColor: "#e56901",
436
+ secondaryIcon: "codicon:vm"
437
+ },
438
+ source: {
439
+ package: "@highstate/proxmox",
440
+ path: "virtual-machine"
441
+ }
442
+ });`
443
+
444
+ const result = await applySchemaTransformations(input)
445
+
446
+ // Should transform args.ipv4 using $addArgumentDescription helper
447
+ expect(result).toContain("ipv4: $addArgumentDescription({")
448
+ expect(result).toContain("schema: z.discriminatedUnion")
449
+ expect(result).toContain("}, `The IPv4 address configuration for the virtual machine.`)")
450
+
451
+ // Should add .meta() to z.object fields with JSDoc comments inside the discriminated union
452
+ expect(result).toContain(
453
+ 'address: z.string().optional().meta({ title: __camelCaseToHumanReadable("address"), description: `The IPv4 address to assign to the virtual machine.` })',
454
+ )
455
+ expect(result).toContain(
456
+ 'prefix: z.number().default(24).meta({ title: __camelCaseToHumanReadable("prefix"), description: `The CIDR prefix for the IPv4 address.',
457
+ )
458
+ expect(result).toContain("By default, this is set to 24.` })")
459
+ expect(result).toContain(
460
+ 'gateway: z.string().optional().meta({ title: __camelCaseToHumanReadable("gateway"), description: `The IPv4 gateway for the virtual machine.',
461
+ )
462
+ expect(result).toContain(
463
+ "If not specified, will be set to the first address in the subnet.` })",
464
+ )
465
+
466
+ // Should NOT transform fields without JSDoc comments
467
+ expect(result).toContain("nodeName: z.string().optional(),") // unchanged
468
+ expect(result).toContain('cpuType: z.string().default("host"),') // unchanged
469
+ expect(result).toContain("cores: z.number().default(1),") // unchanged
470
+ expect(result).toContain("dns: z.string().array().optional(),") // unchanged
471
+ expect(result).toContain('type: z.literal("dhcp")') // unchanged
472
+ expect(result).toContain('type: z.literal("static"),') // unchanged
473
+
474
+ // Should properly handle inputs.vendorData using $addInputDescription helper
475
+ expect(result).toContain("vendorData: $addInputDescription({")
476
+ expect(result).toContain("entity: fileEntity,")
477
+ expect(result).toContain("required: false,")
478
+ expect(result).toContain("}, `The cloud-init vendor data to use for the virtual machine.")
479
+ expect(result).toContain("You can provide a cloud-config from the distribution component.`)")
480
+
481
+ // Should NOT transform other inputs without JSDoc
482
+ expect(result).toContain("proxmoxCluster: clusterEntity,") // unchanged
483
+ expect(result).toContain("image: imageEntity,") // unchanged
484
+ })
485
+
486
+ it("should add description to defineUnit function with JSDoc", async () => {
487
+ const input = `
488
+ /**
489
+ * Installs the Gateway API CRDs to the cluster.
490
+ */
491
+ export const gatewayApi = defineUnit({
492
+ type: "k8s.gateway-api",
493
+ inputs: {
494
+ k8sCluster: clusterEntity,
495
+ },
496
+ outputs: {
497
+ k8sCluster: clusterEntity,
498
+ },
499
+ meta: {
500
+ title: "Gateway API",
501
+ icon: "devicon:kubernetes",
502
+ secondaryIcon: "mdi:api",
503
+ secondaryIconColor: "#4CAF50",
504
+ category: "Kubernetes",
505
+ },
506
+ source: {
507
+ package: "@highstate/k8s",
508
+ path: "units/gateway-api",
509
+ },
510
+ })`
511
+
512
+ const result = await applySchemaTransformations(input)
513
+
514
+ expect(result).toContain("description: `Installs the Gateway API CRDs to the cluster.`")
515
+ expect(result).toContain('title: "Gateway API"')
516
+ expect(result).toContain('icon: "devicon:kubernetes"')
517
+ })
518
+
519
+ it("should add meta field to defineEntity without existing meta", async () => {
520
+ const input = `
521
+ /**
522
+ * Represents a Kubernetes cluster.
523
+ */
524
+ export const clusterEntity = defineEntity({
525
+ type: "k8s.cluster",
526
+ schema: z.object({
527
+ name: z.string(),
528
+ endpoint: z.string(),
529
+ }),
530
+ })`
531
+
532
+ const result = await applySchemaTransformations(input)
533
+
534
+ expect(result).toContain("meta: {")
535
+ expect(result).toContain("description: `Represents a Kubernetes cluster.`")
536
+ expect(result).toContain("schema: z.object({")
537
+ })
538
+
539
+ it("should add description to defineComponent function", async () => {
540
+ const input = `
541
+ /**
542
+ * A reusable database component.
543
+ */
544
+ export const database = defineComponent({
545
+ type: "database",
546
+ components: {
547
+ server: serverUnit,
548
+ storage: storageUnit,
549
+ },
550
+ meta: {
551
+ category: "Database",
552
+ },
553
+ })`
554
+
555
+ const result = await applySchemaTransformations(input)
556
+
557
+ expect(result).toContain("description: `A reusable database component.`")
558
+ expect(result).toContain('category: "Database"')
559
+ })
560
+
561
+ it("should not transform define functions without JSDoc", async () => {
562
+ const input = `
563
+ export const simpleUnit = defineUnit({
564
+ type: "simple",
565
+ meta: {
566
+ title: "Simple Unit",
567
+ },
568
+ })`
569
+
570
+ const result = await applySchemaTransformations(input)
571
+
572
+ expect(result).not.toContain("description:")
573
+ expect(result).toContain('title: "Simple Unit"')
574
+ })
575
+
576
+ it("should not create nested meta.meta.description structure", async () => {
577
+ const input = `
578
+ /**
579
+ * Traefik Gateway unit definition for Kubernetes.
580
+ */
581
+ var traefikGateway = defineUnit({
582
+ type: "k8s.apps.traefik-gateway.v1",
583
+ meta: {
584
+ title: "Traefik Gateway",
585
+ icon: "simple-icons:traefikproxy",
586
+ category: "Network",
587
+ },
588
+ })`
589
+
590
+ const result = await applySchemaTransformations(input)
591
+
592
+ // Should have description at the correct level
593
+ expect(result).toContain("meta: {")
594
+ expect(result).toContain("description: `Traefik Gateway unit definition for Kubernetes.`")
595
+ expect(result).toContain('title: "Traefik Gateway"')
596
+ expect(result).toContain('icon: "simple-icons:traefikproxy"')
597
+ expect(result).toContain('category: "Network"')
598
+
599
+ // Should NOT have nested meta.meta structure
600
+ expect(result).not.toContain("meta: {\\s*meta: {")
601
+
602
+ // Verify the structure more precisely - should only have one meta object
603
+ const metaMatches = (result.match(/meta\s*:\s*\{/g) || []).length
604
+ expect(metaMatches).toBe(1)
605
+ })
606
+
607
+ it("should replace existing description in defineUnit meta object", async () => {
608
+ const input = `
609
+ /**
610
+ * Updated description for the unit.
611
+ */
612
+ export const testUnit = defineUnit({
613
+ type: "test",
614
+ meta: {
615
+ title: "Test Unit",
616
+ description: \`Old description.\`,
617
+ category: "Test",
618
+ },
619
+ })`
620
+
621
+ const result = await applySchemaTransformations(input)
622
+
623
+ expect(result).toContain("description: `Updated description for the unit.`")
624
+ expect(result).not.toContain("Old description")
625
+ expect(result).toContain('title: "Test Unit"')
626
+ expect(result).toContain('category: "Test"')
627
+
628
+ // Should still only have one meta object
629
+ const metaMatches = (result.match(/meta\s*:\s*\{/g) || []).length
630
+ expect(metaMatches).toBe(1)
208
631
  })
209
632
  })