@highstate/cli 0.9.14 → 0.9.16

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.
@@ -0,0 +1,113 @@
1
+ import type { Logger } from "pino"
2
+ import {
3
+ type Component,
4
+ type ComponentModel,
5
+ type Entity,
6
+ isComponent,
7
+ isEntity,
8
+ isUnitModel,
9
+ originalCreate,
10
+ } from "@highstate/contract"
11
+ import { serializeFunction } from "@pulumi/pulumi/runtime/index.js"
12
+ import { Crc32, crc32 } from "@aws-crypto/crc32"
13
+ import { encode } from "@msgpack/msgpack"
14
+ import { int32ToBytes } from "@highstate/backend/shared"
15
+
16
+ export type Library = Readonly<{
17
+ components: Readonly<Record<string, ComponentModel>>
18
+ entities: Readonly<Record<string, Entity>>
19
+ }>
20
+
21
+ export async function loadLibrary(logger: Logger, modulePaths: string[]): Promise<Library> {
22
+ const modules: Record<string, unknown> = {}
23
+ for (const modulePath of modulePaths) {
24
+ try {
25
+ logger.debug({ modulePath }, "loading module")
26
+ modules[modulePath] = await import(modulePath)
27
+
28
+ logger.debug({ modulePath }, "module loaded")
29
+ } catch (err) {
30
+ logger.error({ modulePath, err }, "module load failed")
31
+ }
32
+ }
33
+
34
+ const components: Record<string, ComponentModel> = {}
35
+ const entities: Record<string, Entity> = {}
36
+
37
+ await _loadLibrary(modules, components, entities)
38
+
39
+ logger.info(
40
+ {
41
+ componentCount: Object.keys(components).length,
42
+ entityCount: Object.keys(entities).length,
43
+ },
44
+ "library loaded",
45
+ )
46
+
47
+ logger.trace({ components, entities }, "library content")
48
+
49
+ return { components, entities }
50
+ }
51
+
52
+ async function _loadLibrary(
53
+ value: unknown,
54
+ components: Record<string, ComponentModel>,
55
+ entities: Record<string, Entity>,
56
+ ): Promise<void> {
57
+ if (isComponent(value)) {
58
+ const entityHashes: number[] = []
59
+ for (const entity of value.entities.values()) {
60
+ entity.definitionHash ??= calculateEntityDefinitionHash(entity)
61
+ entityHashes.push(entity.definitionHash)
62
+ }
63
+
64
+ components[value.model.type] = value.model
65
+ value.model.definitionHash = await calculateComponentDefinitionHash(value, entityHashes)
66
+
67
+ return
68
+ }
69
+
70
+ if (isEntity(value)) {
71
+ entities[value.type] = value
72
+ entities[value.type].definitionHash ??= calculateEntityDefinitionHash(value)
73
+
74
+ // @ts-expect-error remove the schema since it's not needed in the designer
75
+ delete value.schema
76
+ return
77
+ }
78
+
79
+ if (typeof value !== "object" || value === null) {
80
+ return
81
+ }
82
+
83
+ for (const key in value) {
84
+ await _loadLibrary((value as Record<string, unknown>)[key], components, entities)
85
+ }
86
+ }
87
+
88
+ async function calculateComponentDefinitionHash(
89
+ component: Component,
90
+ entityHashes: number[],
91
+ ): Promise<number> {
92
+ const result = new Crc32()
93
+
94
+ // 1. include the full component model
95
+ result.update(encode(component.model))
96
+
97
+ if (!isUnitModel(component.model)) {
98
+ // 2. for composite components, include the content of the serialized create function
99
+ const serializedCreate = await serializeFunction(component[originalCreate])
100
+ result.update(Buffer.from(serializedCreate.text))
101
+ }
102
+
103
+ // 3. include the hashes of all entities
104
+ for (const entityHash of entityHashes) {
105
+ result.update(int32ToBytes(entityHash))
106
+ }
107
+
108
+ return result.digest()
109
+ }
110
+
111
+ function calculateEntityDefinitionHash(entity: Entity): number {
112
+ return crc32(encode(entity))
113
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { applySchemaTransformations } from "./schema-transformer"
3
+
4
+ describe("applySchemaTransformations", () => {
5
+ it("should transform simple values to entity/schema structure", async () => {
6
+ const input = `
7
+ const spec = {
8
+ inputs: {
9
+ /**
10
+ * The Kubernetes cluster to deploy on.
11
+ */
12
+ cluster: clusterEntity,
13
+ },
14
+ args: {
15
+ /**
16
+ * The port number to use.
17
+ */
18
+ port: Type.Number(),
19
+ },
20
+ }`
21
+
22
+ const result = await applySchemaTransformations(input)
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.`")
28
+ })
29
+
30
+ it("should inject description into existing meta field", async () => {
31
+ const input = `
32
+ const spec = {
33
+ args: {
34
+ /**
35
+ * The API token for authentication.
36
+ */
37
+ token: {
38
+ schema: Type.String(),
39
+ meta: {
40
+ displayName: "API Token",
41
+ sensitive: true,
42
+ },
43
+ },
44
+ },
45
+ }`
46
+
47
+ const result = await applySchemaTransformations(input)
48
+
49
+ expect(result).toContain('displayName: "API Token"')
50
+ 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) =>")
54
+ })
55
+
56
+ it("should add meta field if it doesn't exist in structured object", async () => {
57
+ const input = `
58
+ const spec = {
59
+ inputs: {
60
+ /**
61
+ * The target endpoint.
62
+ */
63
+ endpoint: {
64
+ entity: endpointEntity,
65
+ required: false,
66
+ },
67
+ },
68
+ }`
69
+
70
+ const result = await applySchemaTransformations(input)
71
+
72
+ expect(result).toContain("entity: endpointEntity")
73
+ expect(result).toContain("required: false")
74
+ expect(result).toContain("meta: {")
75
+ expect(result).toContain("description: `The target endpoint.`")
76
+ })
77
+
78
+ it("should handle $args marker function", async () => {
79
+ const input = `
80
+ const spec = {
81
+ args: $args({
82
+ /**
83
+ * The configuration file path.
84
+ */
85
+ configPath: Type.String(),
86
+ }),
87
+ }`
88
+
89
+ const result = await applySchemaTransformations(input)
90
+
91
+ expect(result).toContain("schema: Type.String()")
92
+ expect(result).toContain("description: `The configuration file path.`")
93
+ })
94
+
95
+ it("should handle $inputs marker function", async () => {
96
+ const input = `
97
+ const spec = {
98
+ inputs: $inputs({
99
+ /**
100
+ * The source data.
101
+ */
102
+ source: dataEntity,
103
+ }),
104
+ }`
105
+
106
+ const result = await applySchemaTransformations(input)
107
+
108
+ expect(result).toContain("entity: dataEntity")
109
+ expect(result).toContain("description: `The source data.`")
110
+ })
111
+
112
+ it("should handle $outputs marker function", async () => {
113
+ const input = `
114
+ const spec = {
115
+ outputs: $outputs({
116
+ /**
117
+ * The processed result.
118
+ */
119
+ result: resultEntity,
120
+ }),
121
+ }`
122
+
123
+ const result = await applySchemaTransformations(input)
124
+
125
+ expect(result).toContain("entity: resultEntity")
126
+ expect(result).toContain("description: `The processed result.`")
127
+ })
128
+
129
+ it("should handle $secrets marker function", async () => {
130
+ const input = `
131
+ const spec = {
132
+ secrets: $secrets({
133
+ /**
134
+ * The database password.
135
+ */
136
+ dbPassword: Type.String(),
137
+ }),
138
+ }`
139
+
140
+ const result = await applySchemaTransformations(input)
141
+
142
+ expect(result).toContain("schema: Type.String()")
143
+ expect(result).toContain("description: `The database password.`")
144
+ })
145
+
146
+ it("should ignore properties without JSDoc comments", async () => {
147
+ const input = `
148
+ const spec = {
149
+ inputs: {
150
+ cluster: clusterEntity,
151
+ /**
152
+ * Only this one has a comment.
153
+ */
154
+ endpoint: endpointEntity,
155
+ },
156
+ }`
157
+
158
+ const result = await applySchemaTransformations(input)
159
+
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.`")
163
+ })
164
+
165
+ it("should ignore properties not in target objects", async () => {
166
+ const input = `
167
+ const config = {
168
+ /**
169
+ * This should not be transformed.
170
+ */
171
+ someProperty: "value",
172
+ }
173
+
174
+ const spec = {
175
+ inputs: {
176
+ /**
177
+ * This should be transformed.
178
+ */
179
+ cluster: clusterEntity,
180
+ },
181
+ }`
182
+
183
+ const result = await applySchemaTransformations(input)
184
+
185
+ expect(result).toContain('someProperty: "value"') // unchanged
186
+ expect(result).toContain("entity: clusterEntity") // transformed
187
+ })
188
+
189
+ it("should clean JSDoc comments properly", async () => {
190
+ const input = `
191
+ const spec = {
192
+ args: {
193
+ /**
194
+ * This is a description with \`backticks\` and \${template} literals.
195
+ * It also has multiple lines.
196
+ */
197
+ value: Type.String(),
198
+ },
199
+ }`
200
+
201
+ const result = await applySchemaTransformations(input)
202
+
203
+ expect(result).toContain("schema: Type.String()")
204
+ expect(result).toContain(
205
+ "description: `This is a description with \\`backticks\\` and \\${template} literals.",
206
+ )
207
+ expect(result).toContain("It also has multiple lines.`")
208
+ })
209
+ })
@@ -22,31 +22,135 @@ export async function applySchemaTransformations(content: string): Promise<strin
22
22
  const magicString = new MagicString(content)
23
23
  const { program, comments } = await parseAsync("file.ts", content)
24
24
 
25
+ const parentStack: Node[] = []
26
+
25
27
  walk(program, {
26
28
  enter(node) {
29
+ parentStack.push(node)
30
+
27
31
  if (node.type !== "Property" || node.key.type !== "Identifier") {
28
32
  return
29
33
  }
30
34
 
35
+ const parentKey = getParentObjectKey(parentStack) || getMarkerFunctionName(parentStack)
36
+ if (!parentKey || !["inputs", "outputs", "args", "secrets"].includes(parentKey)) {
37
+ return
38
+ }
39
+
31
40
  const jsdoc = comments.find(comment => isLeadingComment(content, node, comment))
32
- if (!jsdoc || !jsdoc.value.includes("@schema")) {
41
+ if (!jsdoc) {
33
42
  return
34
43
  }
35
44
 
36
- magicString.update(
37
- node.value.start,
38
- node.value.end,
39
- `{
40
- ...${content.substring(node.value.start, node.value.end)},
41
- description: \`${cleanJsdoc(jsdoc.value)}\`,
45
+ const description = cleanJsdoc(jsdoc.value)
46
+ const originalValue = content.substring(node.value.start, node.value.end)
47
+
48
+ const entityField = ["inputs", "outputs"].includes(parentKey) ? "entity" : "schema"
49
+
50
+ // Check if the value already has entity/schema structure
51
+ const isAlreadyStructured = isStructuredValue(originalValue, entityField)
52
+
53
+ if (isAlreadyStructured) {
54
+ // For already structured values, inject description directly into the object
55
+ const modifiedValue = injectDescriptionIntoObject(originalValue, description)
56
+ magicString.update(node.value.start, node.value.end, modifiedValue)
57
+ } else {
58
+ // Transform to new structure
59
+ magicString.update(
60
+ node.value.start,
61
+ node.value.end,
62
+ `{
63
+ ${entityField}: ${originalValue},
64
+ meta: {
65
+ description: \`${description}\`,
66
+ },
42
67
  }`,
43
- )
68
+ )
69
+ }
70
+ },
71
+ leave() {
72
+ parentStack.pop()
44
73
  },
45
74
  })
46
75
 
47
76
  return magicString.toString()
48
77
  }
49
78
 
79
+ function injectDescriptionIntoObject(objectString: string, description: string): string {
80
+ const trimmed = objectString.trim()
81
+
82
+ // Check if the object already has a meta field
83
+ const metaRegex = /meta\s*:\s*\{/
84
+
85
+ if (metaRegex.test(trimmed)) {
86
+ // Find the meta field and inject description into it
87
+ return trimmed.replace(
88
+ /meta\s*:\s*\{/,
89
+ `meta: {
90
+ description: \`${description}\`,`,
91
+ )
92
+ } else {
93
+ // Add meta field at the end of the object (before the closing brace)
94
+ const lastBraceIndex = trimmed.lastIndexOf("}")
95
+ if (lastBraceIndex === -1) {
96
+ // Invalid object structure, return as is
97
+ return trimmed
98
+ }
99
+
100
+ const beforeBrace = trimmed.substring(0, lastBraceIndex)
101
+ const afterBrace = trimmed.substring(lastBraceIndex)
102
+
103
+ // Check if we need a comma before adding meta
104
+ const needsComma = beforeBrace.trim().length > 1 && !beforeBrace.trim().endsWith(",")
105
+ const comma = needsComma ? "," : ""
106
+
107
+ return `${beforeBrace}${comma}
108
+ meta: {
109
+ description: \`${description}\`,
110
+ },
111
+ ${afterBrace}`
112
+ }
113
+ }
114
+
115
+ function isStructuredValue(value: string, expectedField: string): boolean {
116
+ const trimmed = value.trim()
117
+ if (!trimmed.startsWith("{")) {
118
+ return false
119
+ }
120
+
121
+ // Check if it contains the expected field (entity or schema) at the top level
122
+ const fieldPattern = new RegExp(`^\\s*{[^}]*\\b${expectedField}\\s*:`, "s")
123
+ return fieldPattern.test(trimmed)
124
+ }
125
+
126
+ function getMarkerFunctionName(parentStack: Node[]): string | null {
127
+ // Look for marker functions like $args, $inputs, $outputs, $secrets
128
+ for (let i = parentStack.length - 1; i >= 0; i--) {
129
+ const node = parentStack[i]
130
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
131
+ const functionName = node.callee.name
132
+ if (
133
+ functionName.startsWith("$") &&
134
+ ["$args", "$inputs", "$outputs", "$secrets"].includes(functionName)
135
+ ) {
136
+ return functionName.substring(1) // Remove the $ prefix
137
+ }
138
+ }
139
+ }
140
+ return null
141
+ }
142
+
143
+ function getParentObjectKey(parentStack: Node[]): string | null {
144
+ // Walk up the parent stack to find the parent object property
145
+ for (let i = parentStack.length - 2; i >= 0; i--) {
146
+ const node = parentStack[i]
147
+ if (node.type === "Property" && node.key.type === "Identifier") {
148
+ return node.key.name
149
+ }
150
+ }
151
+ return null
152
+ }
153
+
50
154
  function isLeadingComment(content: string, node: Node, comment: Comment) {
51
155
  if (comment.end > node.start) {
52
156
  return false
@@ -63,9 +167,6 @@ function cleanJsdoc(str: string) {
63
167
  // remove leading asterisks
64
168
  .replace(/^\s*\*/gm, "")
65
169
 
66
- // remove @schema tag
67
- .replace("@schema", "")
68
-
69
170
  // escape backticks and dollar signs
70
171
  .replace(/\\/g, "\\\\")
71
172
  .replace(/`/g, "\\`")
@@ -0,0 +1,41 @@
1
+ import { z } from "zod"
2
+
3
+ /**
4
+ * Schema for the sourceHash configuration in package.json
5
+ */
6
+ export const sourceHashConfigSchema = z.discriminatedUnion("mode", [
7
+ z.object({
8
+ mode: z.literal("manual"),
9
+ version: z.string(),
10
+ }),
11
+ z.object({
12
+ mode: z.literal("auto"),
13
+ }),
14
+ z.object({
15
+ mode: z.literal("version"),
16
+ }),
17
+ z.object({
18
+ mode: z.literal("none"),
19
+ }),
20
+ ])
21
+
22
+ /**
23
+ * Schema for the highstate configuration in package.json
24
+ */
25
+ export const highstateConfigSchema = z.object({
26
+ type: z.enum(["source", "library", "worker"]).default("source"),
27
+ sourceHash: z
28
+ .union([sourceHashConfigSchema, z.record(z.string(), sourceHashConfigSchema)])
29
+ .optional(),
30
+ })
31
+
32
+ /**
33
+ * Schema for the highstate manifest file
34
+ */
35
+ export const highstateManifestSchema = z.object({
36
+ sourceHashes: z.record(z.string(), z.number()).optional(),
37
+ })
38
+
39
+ export type SourceHashConfig = z.infer<typeof sourceHashConfigSchema>
40
+ export type HighstateConfig = z.infer<typeof highstateConfigSchema>
41
+ export type HighstateManifest = z.infer<typeof highstateManifestSchema>