@sanity/cli 3.86.0 → 3.86.2-experimental.0

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,15 +1,18 @@
1
+ /* eslint-disable complexity */
2
+ /* eslint-disable max-statements */
3
+ /* eslint-disable max-depth */
1
4
  import {constants, mkdir, open, stat} from 'node:fs/promises'
2
5
  import {dirname, join} from 'node:path'
3
6
  import {Worker} from 'node:worker_threads'
4
7
 
5
- import {readConfig} from '@sanity/codegen'
6
- import {format as prettierFormat, resolveConfig as resolvePrettierConfig} from 'prettier'
8
+ import {readConfig, type TypeEvaluationStats} from '@sanity/codegen'
7
9
 
8
10
  import {type CliCommandArguments, type CliCommandContext} from '../../types'
9
11
  import {getCliWorkerPath} from '../../util/cliWorker'
12
+ import {createReceiver} from '../../util/workerChannel'
10
13
  import {
11
14
  type TypegenGenerateTypesWorkerData,
12
- type TypegenGenerateTypesWorkerMessage,
15
+ type TypegenWorkerChannel,
13
16
  } from '../../workers/typegenGenerate'
14
17
  import {TypesGeneratedTrace} from './generate.telemetry'
15
18
 
@@ -31,6 +34,51 @@ const generatedFileWarning = `/**
31
34
  * ---------------------------------------------------------------------------------
32
35
  */\n\n`
33
36
 
37
+ /**
38
+ * Helper function to format the generated file using Prettier.
39
+ * Handles its own file operations and error reporting.
40
+ */
41
+ async function formatGeneratedFile(
42
+ outputPath: string,
43
+ output: CliCommandContext['output'],
44
+ spinner: ReturnType<CliCommandContext['output']['spinner']>,
45
+ ): Promise<void> {
46
+ // this is here because this is an ESM-only import
47
+ const {format: prettierFormat, resolveConfig: resolvePrettierConfig} = await import('prettier')
48
+
49
+ let formatFile
50
+ try {
51
+ // Load prettier config
52
+ const prettierConfig = await resolvePrettierConfig(outputPath).catch((err) => {
53
+ output.warn(`Failed to load prettier config: ${err.message}`)
54
+ return null
55
+ })
56
+
57
+ if (prettierConfig) {
58
+ spinner.text = 'Formatting generated types with Prettier...'
59
+ formatFile = await open(outputPath, constants.O_RDWR)
60
+ try {
61
+ const code = await formatFile.readFile()
62
+ const formattedCode = await prettierFormat(code.toString(), {
63
+ ...prettierConfig,
64
+ parser: 'typescript' as const,
65
+ })
66
+ await formatFile.truncate() // Truncate before writing formatted code
67
+ await formatFile.write(formattedCode, 0) // Write formatted code from the beginning
68
+ spinner.info('Formatted generated types with Prettier')
69
+ } catch (err) {
70
+ output.warn(`Failed to format generated types with Prettier: ${err.message}`)
71
+ } finally {
72
+ // Ensure the formatting file handle is closed
73
+ await formatFile?.close()
74
+ }
75
+ }
76
+ } catch (err) {
77
+ // Catch errors during the formatting setup (e.g., opening the formatFile)
78
+ output.warn(`Error during formatting setup: ${err.message}`)
79
+ }
80
+ }
81
+
34
82
  export default async function typegenGenerateAction(
35
83
  args: CliCommandArguments<TypegenGenerateTypesCommandFlags>,
36
84
  context: CliCommandContext,
@@ -41,170 +89,157 @@ export default async function typegenGenerateAction(
41
89
  const trace = telemetry.trace(TypesGeneratedTrace)
42
90
  trace.start()
43
91
 
44
- const codegenConfig = await readConfig(flags['config-path'] || 'sanity-typegen.json')
92
+ const typegenConfig = await readConfig(flags['config-path'] ?? 'sanity-typegen.json')
45
93
 
46
- try {
47
- const schemaStats = await stat(codegenConfig.schema)
48
- if (!schemaStats.isFile()) {
49
- throw new Error(`Schema path is not a file: ${codegenConfig.schema}`)
94
+ const missingSchemas: string[] = []
95
+ const invalidSchemas: string[] = []
96
+
97
+ for (const schemaPath of typegenConfig.schemas.map((i) => i.schemaPath)) {
98
+ try {
99
+ const schemaStats = await stat(schemaPath)
100
+ if (!schemaStats.isFile()) {
101
+ invalidSchemas.push(schemaPath)
102
+ }
103
+ } catch (err) {
104
+ if (err.code === 'ENOENT') {
105
+ missingSchemas.push(schemaPath)
106
+ } else {
107
+ throw err
108
+ }
50
109
  }
51
- } catch (err) {
52
- if (err.code === 'ENOENT') {
110
+ }
111
+
112
+ if (missingSchemas.length > 0 || invalidSchemas.length > 0) {
113
+ const errors: string[] = []
114
+
115
+ if (missingSchemas.length > 0) {
53
116
  // If the user has not provided a specific schema path (eg we're using the default), give some help
54
117
  const hint =
55
- codegenConfig.schema === './schema.json' ? ` - did you run "sanity schema extract"?` : ''
56
- throw new Error(`Schema file not found: ${codegenConfig.schema}${hint}`)
118
+ missingSchemas.length === 1 && missingSchemas[0] === './schema.json'
119
+ ? ' - did you run "sanity schema extract"?'
120
+ : ''
121
+ const schemaList = missingSchemas.map((path) => ` - ${path}`).join('\n')
122
+ errors.push(`The following schema files were not found:\n${schemaList}${hint}`)
123
+ }
124
+
125
+ if (invalidSchemas.length > 0) {
126
+ const schemaList = invalidSchemas.map((path) => ` - ${path}`).join('\n')
127
+ errors.push(`The following schema paths are not files:\n${schemaList}`)
57
128
  }
58
- throw err
129
+
130
+ throw new Error(errors.join('\n\n'))
59
131
  }
60
132
 
61
- const outputPath = join(process.cwd(), codegenConfig.generates)
133
+ const outputPath = join(process.cwd(), typegenConfig.generates)
62
134
  const outputDir = dirname(outputPath)
63
135
  await mkdir(outputDir, {recursive: true})
64
136
  const workerPath = await getCliWorkerPath('typegenGenerate')
65
137
 
66
- const spinner = output.spinner({}).start('Generating types')
138
+ const spinner = output.spinner('Generating types')
67
139
 
68
140
  const worker = new Worker(workerPath, {
69
141
  workerData: {
70
142
  workDir,
71
- schemaPath: codegenConfig.schema,
72
- searchPath: codegenConfig.path,
73
- overloadClientMethods: codegenConfig.overloadClientMethods,
143
+ schemas: typegenConfig.schemas,
144
+ searchPath: typegenConfig.path,
145
+ overloadClientMethods: typegenConfig.overloadClientMethods,
146
+ augmentGroqModule: typegenConfig.augmentGroqModule,
74
147
  } satisfies TypegenGenerateTypesWorkerData,
75
148
  // eslint-disable-next-line no-process-env
76
149
  env: process.env,
77
150
  })
78
151
 
79
- const typeFile = await open(
80
- outputPath,
81
- // eslint-disable-next-line no-bitwise
82
- constants.O_TRUNC | constants.O_CREAT | constants.O_WRONLY,
83
- )
84
-
85
- typeFile.write(generatedFileWarning)
152
+ const receiver = createReceiver<TypegenWorkerChannel>(worker)
86
153
 
154
+ let fileHandle
87
155
  const stats = {
88
156
  queryFilesCount: 0,
89
- errors: 0,
90
157
  queriesCount: 0,
158
+ projectionsCount: 0,
91
159
  schemaTypesCount: 0,
92
- unknownTypeNodesGenerated: 0,
93
- typeNodesGenerated: 0,
94
- emptyUnionTypeNodesGenerated: 0,
95
- size: 0,
160
+ typeEvaluationStats: null as TypeEvaluationStats | null,
161
+ outputSize: 0,
162
+ filesWithErrors: 0,
96
163
  }
97
164
 
98
- await new Promise<void>((resolve, reject) => {
99
- worker.addListener('message', (msg: TypegenGenerateTypesWorkerMessage) => {
100
- if (msg.type === 'error') {
101
- if (msg.fatal) {
102
- trace.error(msg.error)
103
- reject(msg.error)
104
- return
105
- }
106
- const errorMessage = msg.filename
107
- ? `${msg.error.message} in "${msg.filename}"`
108
- : msg.error.message
109
- spinner.fail(errorMessage)
110
- stats.errors++
111
- return
112
- }
113
- if (msg.type === 'complete') {
114
- resolve()
115
- return
116
- }
117
-
118
- if (msg.type === 'typemap') {
119
- let typeMapStr = `// Query TypeMap\n`
120
- typeMapStr += msg.typeMap
121
- typeFile.write(typeMapStr)
122
- stats.size += Buffer.byteLength(typeMapStr)
123
- return
124
- }
165
+ try {
166
+ try {
167
+ spinner.start()
125
168
 
126
- let fileTypeString = `// Source: ${msg.filename}\n`
169
+ fileHandle = await open(outputPath, 'w')
170
+ await fileHandle.write(generatedFileWarning)
127
171
 
128
- if (msg.type === 'schema') {
129
- stats.schemaTypesCount += msg.length
130
- fileTypeString += msg.schema
131
- typeFile.write(fileTypeString)
132
- return
133
- }
172
+ spinner.text = 'Generating schema types...'
173
+ const {code: schemaCode, schemas} = await receiver.event.schema()
174
+ stats.schemaTypesCount = schemas.reduce((total, schema) => total + schema.typeCount, 0)
175
+ await fileHandle.write(schemaCode)
134
176
 
135
- if (msg.type === 'types') {
177
+ spinner.text = 'Generating query types...'
178
+ for await (const queryResult of receiver.stream.queries()) {
136
179
  stats.queryFilesCount++
137
- for (const {
138
- queryName,
139
- query,
140
- type,
141
- typeNodesGenerated,
142
- unknownTypeNodesGenerated,
143
- emptyUnionTypeNodesGenerated,
144
- } of msg.types) {
145
- fileTypeString += `// Variable: ${queryName}\n`
146
- fileTypeString += `// Query: ${query.replace(/(\r\n|\n|\r)/gm, '').trim()}\n`
147
- fileTypeString += type
148
- stats.queriesCount++
149
- stats.typeNodesGenerated += typeNodesGenerated
150
- stats.unknownTypeNodesGenerated += unknownTypeNodesGenerated
151
- stats.emptyUnionTypeNodesGenerated += emptyUnionTypeNodesGenerated
180
+ const {error, results} = queryResult
181
+ if (error) {
182
+ stats.filesWithErrors++
183
+ }
184
+ for (const result of results) {
185
+ await fileHandle.write(result.code)
186
+ if (result.type === 'projection') {
187
+ stats.projectionsCount++
188
+ } else {
189
+ stats.queriesCount++
190
+ }
152
191
  }
153
- typeFile.write(`${fileTypeString}\n`)
154
- stats.size += Buffer.byteLength(fileTypeString)
155
192
  }
156
- })
157
- worker.addListener('error', reject)
158
- })
159
-
160
- await typeFile.close()
161
193
 
162
- const prettierConfig = codegenConfig.formatGeneratedCode
163
- ? await resolvePrettierConfig(outputPath).catch((err) => {
164
- output.warn(`Failed to load prettier config: ${err.message}`)
165
- return null
166
- })
167
- : null
168
-
169
- if (prettierConfig) {
170
- const formatFile = await open(outputPath, constants.O_RDWR)
171
- try {
172
- const code = await formatFile.readFile()
173
- const formattedCode = await prettierFormat(code.toString(), {
174
- ...prettierConfig,
175
- parser: 'typescript' as const,
176
- })
177
- await formatFile.truncate()
178
- await formatFile.write(formattedCode, 0)
179
-
180
- spinner.info('Formatted generated types with Prettier')
181
- } catch (err) {
182
- output.warn(`Failed to format generated types with Prettier: ${err.message}`)
194
+ spinner.text = 'Generating typemap...'
195
+ const {code: typemapCode, stats: finalStats} = await receiver.event.typemap()
196
+ stats.typeEvaluationStats = finalStats
197
+ await fileHandle.write(typemapCode)
183
198
  } finally {
184
- await formatFile.close()
199
+ // Ensure the initial file handle is closed before moving on
200
+ await fileHandle?.close()
185
201
  }
186
- }
187
202
 
188
- trace.log({
189
- outputSize: stats.size,
190
- queriesCount: stats.queriesCount,
191
- schemaTypesCount: stats.schemaTypesCount,
192
- queryFilesCount: stats.queryFilesCount,
193
- filesWithErrors: stats.errors,
194
- typeNodesGenerated: stats.typeNodesGenerated,
195
- unknownTypeNodesGenerated: stats.unknownTypeNodesGenerated,
196
- unknownTypeNodesRatio:
197
- stats.typeNodesGenerated > 0 ? stats.unknownTypeNodesGenerated / stats.typeNodesGenerated : 0,
198
- emptyUnionTypeNodesGenerated: stats.emptyUnionTypeNodesGenerated,
199
- configOverloadClientMethods: codegenConfig.overloadClientMethods,
200
- })
203
+ if (typegenConfig.formatGeneratedCode) {
204
+ await formatGeneratedFile(outputPath, output, spinner)
205
+ }
201
206
 
202
- trace.complete()
203
- if (stats.errors > 0) {
204
- spinner.warn(`Encountered errors in ${stats.errors} files while generating types`)
205
- }
207
+ // Gather final stats and report success
208
+ const finalStat = await stat(outputPath)
209
+ stats.outputSize = finalStat.size
210
+
211
+ trace.log({
212
+ outputSize: stats.outputSize,
213
+ queriesCount: stats.queriesCount,
214
+ projectionsCount: stats.projectionsCount,
215
+ schemaTypesCount: stats.schemaTypesCount,
216
+ queryFilesCount: stats.queryFilesCount,
217
+ filesWithErrors: stats.filesWithErrors,
218
+ typeNodesGenerated: stats.typeEvaluationStats?.totalTypeNodes,
219
+ unknownTypeNodesGenerated: stats.typeEvaluationStats?.unknownTypeCount,
220
+ unknownTypeNodesRatio:
221
+ stats.typeEvaluationStats && stats.typeEvaluationStats.totalTypeNodes > 0
222
+ ? stats.typeEvaluationStats.unknownTypeCount / stats.typeEvaluationStats.totalTypeNodes
223
+ : 0,
224
+ emptyUnionTypeNodesGenerated: stats.typeEvaluationStats?.emptyUnionCount,
225
+ configOverloadClientMethods: typegenConfig.overloadClientMethods,
226
+ configAugmentGroqModule: typegenConfig.augmentGroqModule,
227
+ })
228
+
229
+ trace.complete()
230
+ if (stats.filesWithErrors > 0) {
231
+ spinner.warn(`Encountered errors in ${stats.filesWithErrors} files while generating types`)
232
+ }
206
233
 
207
- spinner.succeed(
208
- `Generated TypeScript types for ${stats.schemaTypesCount} schema types and ${stats.queriesCount} GROQ queries in ${stats.queryFilesCount} files into: ${codegenConfig.generates}`,
209
- )
234
+ spinner.succeed(
235
+ `Generated TypeScript types for ${stats.schemaTypesCount} schema types, ${stats.queriesCount} GROQ queries, ${stats.projectionsCount} GROQ projections, in ${stats.queryFilesCount} files into: ${typegenConfig.generates}`,
236
+ )
237
+ } catch (err) {
238
+ spinner.fail('Type generation failed')
239
+ trace.error(err instanceof Error ? err : new Error(String(err)))
240
+ throw err // Re-throw the error after logging
241
+ } finally {
242
+ // Ensure the worker receiver is always disposed
243
+ await receiver.dispose()
244
+ }
210
245
  }
package/src/cli.ts CHANGED
File without changes
@@ -5,25 +5,25 @@ import {type CliCommandDefinition} from '../../types'
5
5
  type StackFunctionResource = types.StackFunctionResource
6
6
 
7
7
  const helpText = `
8
+ Arguments
9
+ [add] Add or update an environment variable
10
+ [remove] Remove an environment variable
11
+
8
12
  Options
9
13
  --name <name> The name of the function
10
- --add Add or update an environment variable
11
- --remove Remove an environment variable
12
14
  --key <key> The name of the environment variable
13
15
  --value <value> The value of the environment variable
14
16
 
15
17
  Examples
16
18
  # Add or update an environment variable
17
- sanity functions env --name echo --add --key API_URL --value https://api.example.com/
19
+ sanity functions env add --name echo --key API_URL --value https://api.example.com/
18
20
 
19
21
  # Remove an environment variable
20
- sanity functions env --name echo --remove --key API_URL
22
+ sanity functions env remove --name echo --key API_URL
21
23
  `
22
24
 
23
25
  const defaultFlags = {
24
26
  name: '',
25
- add: false,
26
- remove: false,
27
27
  key: '',
28
28
  value: '',
29
29
  }
@@ -38,16 +38,20 @@ const envFunctionsCommand: CliCommandDefinition = {
38
38
  async action(args, context) {
39
39
  const {apiClient, output} = context
40
40
  const {print} = output
41
+ const [subCommand] = args.argsWithoutOptions
41
42
  const flags = {...defaultFlags, ...args.extOptions}
42
43
 
44
+ if (!subCommand || !['add', 'remove'].includes(subCommand)) {
45
+ throw new Error('You must specify if you wish to add or remove an environment variable')
46
+ }
47
+
43
48
  const client = apiClient({
44
49
  requireUser: true,
45
50
  requireProject: false,
46
51
  })
47
52
 
48
53
  if (flags.name === '') {
49
- print('You must provide a function name')
50
- return
54
+ throw new Error('You must provide a function name via the --name flag')
51
55
  }
52
56
 
53
57
  const token = client.config().token
@@ -61,8 +65,7 @@ const envFunctionsCommand: CliCommandDefinition = {
61
65
  })
62
66
 
63
67
  if (!deployedStack) {
64
- print('Stack not found')
65
- return
68
+ throw new Error('Stack not found')
66
69
  }
67
70
 
68
71
  const blueprintConfig = blueprint.readConfigFile()
@@ -74,7 +77,7 @@ const envFunctionsCommand: CliCommandDefinition = {
74
77
  ) as StackFunctionResource
75
78
 
76
79
  if (token && projectId) {
77
- if (flags.add) {
80
+ if (subCommand === 'add') {
78
81
  print(`Updating "${flags.key}" environment variable in "${flags.name}"`)
79
82
  const result = await env.update.update(externalId, flags.key, flags.value, {
80
83
  token,
@@ -86,7 +89,7 @@ const envFunctionsCommand: CliCommandDefinition = {
86
89
  print(`Failed to update ${flags.key}`)
87
90
  print(`Error: ${result.error || 'Unknown error'}`)
88
91
  }
89
- } else if (flags.remove) {
92
+ } else if (subCommand === 'remove') {
90
93
  print(`Removing "${flags.key}" environment variable in "${flags.name}"`)
91
94
  const result = await env.remove.remove(externalId, flags.key, {
92
95
  token,
@@ -7,8 +7,9 @@ type StackFunctionResource = types.StackFunctionResource
7
7
  const helpText = `
8
8
  Options
9
9
  --name <name> The name of the function to retrieve logs for
10
- --limit <limit> The number of log entries to retrieve
10
+ --limit <limit> The number of log entries to retrieve [default 50]
11
11
  --json If set return json
12
+ --utc Use UTC dates in logs
12
13
 
13
14
  Examples
14
15
  # Retrieve logs for Sanity Function abcd1234
@@ -25,6 +26,7 @@ const defaultFlags = {
25
26
  name: '',
26
27
  limit: 50,
27
28
  json: false,
29
+ utc: false,
28
30
  }
29
31
 
30
32
  const logsFunctionsCommand: CliCommandDefinition = {
@@ -45,8 +47,7 @@ const logsFunctionsCommand: CliCommandDefinition = {
45
47
  })
46
48
 
47
49
  if (flags.name === '') {
48
- print('You must provide a function name')
49
- return
50
+ throw new Error('You must provide a function name via the --name flag')
50
51
  }
51
52
 
52
53
  const token = client.config().token
@@ -59,8 +60,7 @@ const logsFunctionsCommand: CliCommandDefinition = {
59
60
  })
60
61
 
61
62
  if (!deployedStack) {
62
- print('Stack not found')
63
- return
63
+ throw new Error('Stack not found')
64
64
  }
65
65
 
66
66
  const blueprintConfig = blueprint.readConfigFile()
@@ -105,7 +105,10 @@ const logsFunctionsCommand: CliCommandDefinition = {
105
105
  for (const log of filteredLogs) {
106
106
  const {time, level, message} = log
107
107
  const date = new Date(time)
108
- print(`${date.toLocaleDateString()} ${date.toLocaleTimeString()} ${level} ${message}`)
108
+ const dateString = flags.utc
109
+ ? date.toISOString().slice(0, 19).split('T').join(' ')
110
+ : `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`
111
+ print(`${dateString} ${level} ${message}`)
109
112
  }
110
113
  }
111
114
  } else {
@@ -38,8 +38,7 @@ const testFunctionsCommand: CliCommandDefinition = {
38
38
  const flags = {...defaultFlags, ...args.extOptions}
39
39
 
40
40
  if (flags.name === '') {
41
- print('You must provide a function name')
42
- return
41
+ throw new Error('You must provide a function name via the --name flag')
43
42
  }
44
43
 
45
44
  const {test} = await import('@sanity/runtime-cli/actions/functions')
@@ -52,7 +51,7 @@ const testFunctionsCommand: CliCommandDefinition = {
52
51
 
53
52
  const src = findFunction.getFunctionSource(parsedBlueprint, flags.name)
54
53
  if (!src) {
55
- print(`Error: Function ${flags.name} has no source code`)
54
+ throw new Error(`Error: Function ${flags.name} has no source code`)
56
55
  }
57
56
 
58
57
  const {json, logs, error} = await test.testAction(
File without changes
File without changes