@sanity/cli 3.65.1-export-comments.7 → 3.65.2-cops.39
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.
- package/lib/_chunks-cjs/cli.js +4125 -388
- package/lib/_chunks-cjs/cli.js.map +1 -1
- package/lib/index.d.mts +9 -0
- package/lib/index.d.ts +9 -0
- package/package.json +11 -10
- package/src/actions/init-project/{bootstrapTemplate.ts → bootstrapLocalTemplate.ts} +6 -19
- package/src/actions/init-project/bootstrapRemoteTemplate.ts +69 -0
- package/src/actions/init-project/initProject.ts +110 -72
- package/src/actions/init-project/updateInitialTemplateMetadata.ts +24 -0
- package/src/commands/init/initCommand.ts +7 -58
- package/src/types.ts +9 -0
- package/src/util/getProviderName.ts +9 -0
- package/src/util/remoteTemplate.ts +505 -0
- package/bin/xdg-open +0 -1066
@@ -0,0 +1,505 @@
|
|
1
|
+
import {access, readFile, writeFile} from 'node:fs/promises'
|
2
|
+
import {join, posix, sep} from 'node:path'
|
3
|
+
import {Readable} from 'node:stream'
|
4
|
+
import {pipeline} from 'node:stream/promises'
|
5
|
+
import {type ReadableStream} from 'node:stream/web'
|
6
|
+
|
7
|
+
import {x} from 'tar'
|
8
|
+
import {parse as parseYaml} from 'yaml'
|
9
|
+
|
10
|
+
import {type CliApiClient, type PackageJson} from '../types'
|
11
|
+
|
12
|
+
const ENV_VAR = {
|
13
|
+
PROJECT_ID: /SANITY(?:_STUDIO)?_PROJECT_ID/, // Matches SANITY_PROJECT_ID and SANITY_STUDIO_PROJECT_ID
|
14
|
+
DATASET: /SANITY(?:_STUDIO)?_DATASET/, // Matches SANITY_DATASET and SANITY_STUDIO_DATASET
|
15
|
+
READ_TOKEN: 'SANITY_API_READ_TOKEN',
|
16
|
+
} as const
|
17
|
+
|
18
|
+
const ENV_FILE = {
|
19
|
+
TEMPLATE: '.env.template',
|
20
|
+
EXAMPLE: '.env.example',
|
21
|
+
LOCAL_EXAMPLE: '.env.local.example',
|
22
|
+
} as const
|
23
|
+
|
24
|
+
const ENV_TEMPLATE_FILES = [ENV_FILE.TEMPLATE, ENV_FILE.EXAMPLE, ENV_FILE.LOCAL_EXAMPLE] as const
|
25
|
+
|
26
|
+
type EnvData = {
|
27
|
+
projectId: string
|
28
|
+
dataset: string
|
29
|
+
readToken?: string
|
30
|
+
}
|
31
|
+
|
32
|
+
type GithubUrlString =
|
33
|
+
| `https://github.com/${string}/${string}`
|
34
|
+
| `https://www.github.com/${string}/${string}`
|
35
|
+
|
36
|
+
export type RepoInfo = {
|
37
|
+
username: string
|
38
|
+
name: string
|
39
|
+
branch: string
|
40
|
+
filePath: string
|
41
|
+
}
|
42
|
+
|
43
|
+
function isGithubRepoShorthand(value: string): boolean {
|
44
|
+
if (URL.canParse(value)) {
|
45
|
+
return false
|
46
|
+
}
|
47
|
+
// This supports :owner/:repo and :owner/:repo/nested/path, e.g.
|
48
|
+
// sanity-io/sanity
|
49
|
+
// sanity-io/sanity/templates/next-js
|
50
|
+
// sanity-io/templates/live-content-api
|
51
|
+
// sanity-io/sanity/packages/@sanity/cli/test/test-template
|
52
|
+
return /^[\w-]+\/[\w-.]+(\/[@\w-.]+)*$/.test(value)
|
53
|
+
}
|
54
|
+
|
55
|
+
function isGithubRepoUrl(value: string | URL): value is URL | GithubUrlString {
|
56
|
+
if (URL.canParse(value) === false) {
|
57
|
+
return false
|
58
|
+
}
|
59
|
+
const url = new URL(value)
|
60
|
+
const pathSegments = url.pathname.slice(1).split('/')
|
61
|
+
|
62
|
+
return (
|
63
|
+
url.protocol === 'https:' &&
|
64
|
+
url.hostname === 'github.com' &&
|
65
|
+
// The pathname must have at least 2 segments. If it has more than 2, the
|
66
|
+
// third must be "tree" and it must have at least 4 segments.
|
67
|
+
// https://github.com/:owner/:repo
|
68
|
+
// https://github.com/:owner/:repo/tree/:ref
|
69
|
+
pathSegments.length >= 2 &&
|
70
|
+
(pathSegments.length > 2 ? pathSegments[2] === 'tree' && pathSegments.length >= 4 : true)
|
71
|
+
)
|
72
|
+
}
|
73
|
+
|
74
|
+
async function downloadTarStream(url: string, bearerToken?: string): Promise<Readable> {
|
75
|
+
const headers: Record<string, string> = {}
|
76
|
+
if (bearerToken) {
|
77
|
+
headers.Authorization = `Bearer ${bearerToken}`
|
78
|
+
}
|
79
|
+
|
80
|
+
const res = await fetch(url, {headers})
|
81
|
+
|
82
|
+
if (!res.body) {
|
83
|
+
throw new Error(`Failed to download: ${url}`)
|
84
|
+
}
|
85
|
+
|
86
|
+
return Readable.fromWeb(res.body as ReadableStream)
|
87
|
+
}
|
88
|
+
|
89
|
+
export function checkIsRemoteTemplate(templateName?: string): boolean {
|
90
|
+
return templateName?.includes('/') ?? false
|
91
|
+
}
|
92
|
+
|
93
|
+
export async function getGitHubRepoInfo(value: string, bearerToken?: string): Promise<RepoInfo> {
|
94
|
+
let username = ''
|
95
|
+
let name = ''
|
96
|
+
let branch = ''
|
97
|
+
let filePath = ''
|
98
|
+
|
99
|
+
if (isGithubRepoShorthand(value)) {
|
100
|
+
const parts = value.split('/')
|
101
|
+
username = parts[0]
|
102
|
+
name = parts[1]
|
103
|
+
// If there are more segments after owner/repo, they form the file path
|
104
|
+
if (parts.length > 2) {
|
105
|
+
filePath = parts.slice(2).join('/')
|
106
|
+
}
|
107
|
+
}
|
108
|
+
|
109
|
+
if (isGithubRepoUrl(value)) {
|
110
|
+
const url = new URL(value)
|
111
|
+
const pathSegments = url.pathname.slice(1).split('/')
|
112
|
+
username = pathSegments[0]
|
113
|
+
name = pathSegments[1]
|
114
|
+
|
115
|
+
// If we have a "tree" segment, everything after branch is the file path
|
116
|
+
if (pathSegments[2] === 'tree') {
|
117
|
+
branch = pathSegments[3]
|
118
|
+
if (pathSegments.length > 4) {
|
119
|
+
filePath = pathSegments.slice(4).join('/')
|
120
|
+
}
|
121
|
+
}
|
122
|
+
}
|
123
|
+
|
124
|
+
if (!username || !name) {
|
125
|
+
throw new Error('Invalid GitHub repository format')
|
126
|
+
}
|
127
|
+
|
128
|
+
const tokenMessage =
|
129
|
+
'GitHub repository not found. For private repositories, use --template-token to provide an access token.\n\n' +
|
130
|
+
'You can generate a new token at https://github.com/settings/personal-access-tokens/new\n' +
|
131
|
+
'Set the token to "read-only" with repository access and a short expiry (e.g. 7 days) for security.'
|
132
|
+
|
133
|
+
try {
|
134
|
+
const headers: Record<string, string> = {}
|
135
|
+
if (bearerToken) {
|
136
|
+
headers.Authorization = `Bearer ${bearerToken}`
|
137
|
+
}
|
138
|
+
|
139
|
+
const infoResponse = await fetch(`https://api.github.com/repos/${username}/${name}`, {
|
140
|
+
headers,
|
141
|
+
})
|
142
|
+
|
143
|
+
if (infoResponse.status !== 200) {
|
144
|
+
if (infoResponse.status === 404) {
|
145
|
+
throw new Error(tokenMessage)
|
146
|
+
}
|
147
|
+
throw new Error('GitHub repository not found')
|
148
|
+
}
|
149
|
+
|
150
|
+
const info = await infoResponse.json()
|
151
|
+
|
152
|
+
return {
|
153
|
+
username,
|
154
|
+
name,
|
155
|
+
branch: branch || info.default_branch,
|
156
|
+
filePath,
|
157
|
+
}
|
158
|
+
} catch {
|
159
|
+
throw new Error(tokenMessage)
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
export async function downloadAndExtractRepo(
|
164
|
+
root: string,
|
165
|
+
{username, name, branch, filePath}: RepoInfo,
|
166
|
+
bearerToken?: string,
|
167
|
+
): Promise<void> {
|
168
|
+
let rootPath: string | null = null
|
169
|
+
await pipeline(
|
170
|
+
await downloadTarStream(
|
171
|
+
`https://codeload.github.com/${username}/${name}/tar.gz/${branch}`,
|
172
|
+
bearerToken,
|
173
|
+
),
|
174
|
+
x({
|
175
|
+
cwd: root,
|
176
|
+
strip: filePath ? filePath.split('/').length + 1 : 1,
|
177
|
+
filter: (p: string) => {
|
178
|
+
const posixPath = p.split(sep).join(posix.sep)
|
179
|
+
if (rootPath === null) {
|
180
|
+
const pathSegments = posixPath.split(posix.sep)
|
181
|
+
rootPath = pathSegments.length ? pathSegments[0] : null
|
182
|
+
}
|
183
|
+
return posixPath.startsWith(`${rootPath}${filePath ? `/${filePath}/` : '/'}`)
|
184
|
+
},
|
185
|
+
}),
|
186
|
+
)
|
187
|
+
}
|
188
|
+
|
189
|
+
/**
|
190
|
+
* Checks if a GitHub repository is a monorepo by examining common monorepo configuration files.
|
191
|
+
* Supports pnpm workspaces, Lerna, Rush, and npm workspaces (package.json).
|
192
|
+
* @returns Promise that resolves to an array of package paths/names if monorepo is detected, undefined otherwise
|
193
|
+
*/
|
194
|
+
export async function getMonoRepo(
|
195
|
+
repoInfo: RepoInfo,
|
196
|
+
bearerToken?: string,
|
197
|
+
): Promise<string[] | undefined> {
|
198
|
+
const {username, name, branch, filePath} = repoInfo
|
199
|
+
const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
|
200
|
+
|
201
|
+
const headers: Record<string, string> = {}
|
202
|
+
if (bearerToken) {
|
203
|
+
headers.Authorization = `Bearer ${bearerToken}`
|
204
|
+
}
|
205
|
+
|
206
|
+
type MonorepoHandler = {
|
207
|
+
check: (content: string) => string[] | undefined
|
208
|
+
}
|
209
|
+
|
210
|
+
const handlers: Record<string, MonorepoHandler> = {
|
211
|
+
'package.json': {
|
212
|
+
check: (content) => {
|
213
|
+
try {
|
214
|
+
const pkg = JSON.parse(content)
|
215
|
+
if (!pkg.workspaces) return undefined
|
216
|
+
return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages
|
217
|
+
} catch {
|
218
|
+
return undefined
|
219
|
+
}
|
220
|
+
},
|
221
|
+
},
|
222
|
+
'pnpm-workspace.yaml': {
|
223
|
+
check: (content) => {
|
224
|
+
try {
|
225
|
+
const config = parseYaml(content)
|
226
|
+
return config.packages
|
227
|
+
} catch {
|
228
|
+
return undefined
|
229
|
+
}
|
230
|
+
},
|
231
|
+
},
|
232
|
+
'lerna.json': {
|
233
|
+
check: (content) => {
|
234
|
+
try {
|
235
|
+
const config = JSON.parse(content)
|
236
|
+
return config.packages
|
237
|
+
} catch {
|
238
|
+
return undefined
|
239
|
+
}
|
240
|
+
},
|
241
|
+
},
|
242
|
+
'rush.json': {
|
243
|
+
check: (content) => {
|
244
|
+
try {
|
245
|
+
const config = JSON.parse(content)
|
246
|
+
return config.projects?.map((p: {packageName: string}) => p.packageName)
|
247
|
+
} catch {
|
248
|
+
return undefined
|
249
|
+
}
|
250
|
+
},
|
251
|
+
},
|
252
|
+
}
|
253
|
+
|
254
|
+
const fileChecks = await Promise.all(
|
255
|
+
Object.keys(handlers).map(async (file) => {
|
256
|
+
const response = await fetch(`${baseUrl}/${file}`, {headers})
|
257
|
+
return {file, exists: response.status === 200, content: await response.text()}
|
258
|
+
}),
|
259
|
+
)
|
260
|
+
|
261
|
+
for (const check of fileChecks) {
|
262
|
+
if (!check.exists) continue
|
263
|
+
const result = handlers[check.file].check(check.content)
|
264
|
+
if (result) return result
|
265
|
+
}
|
266
|
+
|
267
|
+
return undefined
|
268
|
+
}
|
269
|
+
|
270
|
+
/**
|
271
|
+
* Validates a single package within a repository against required criteria.
|
272
|
+
*/
|
273
|
+
async function validatePackage(
|
274
|
+
baseUrl: string,
|
275
|
+
packagePath: string,
|
276
|
+
headers: Record<string, string>,
|
277
|
+
): Promise<{
|
278
|
+
hasSanityConfig: boolean
|
279
|
+
hasSanityCli: boolean
|
280
|
+
hasEnvFile: boolean
|
281
|
+
hasSanityDep: boolean
|
282
|
+
}> {
|
283
|
+
const packageUrl = packagePath ? `${baseUrl}/${packagePath}` : baseUrl
|
284
|
+
|
285
|
+
const requiredFiles = [
|
286
|
+
'package.json',
|
287
|
+
'sanity.config.ts',
|
288
|
+
'sanity.config.js',
|
289
|
+
'sanity.cli.ts',
|
290
|
+
'sanity.cli.js',
|
291
|
+
...ENV_TEMPLATE_FILES,
|
292
|
+
]
|
293
|
+
|
294
|
+
const fileChecks = await Promise.all(
|
295
|
+
requiredFiles.map(async (file) => {
|
296
|
+
const response = await fetch(`${packageUrl}/${file}`, {headers})
|
297
|
+
return {file, exists: response.status === 200, content: await response.text()}
|
298
|
+
}),
|
299
|
+
)
|
300
|
+
|
301
|
+
const packageJson = fileChecks.find((f) => f.file === 'package.json')
|
302
|
+
if (!packageJson?.exists) {
|
303
|
+
throw new Error(`Package at ${packagePath || 'root'} must include a package.json file`)
|
304
|
+
}
|
305
|
+
|
306
|
+
let hasSanityDep = false
|
307
|
+
try {
|
308
|
+
const pkg: PackageJson = JSON.parse(packageJson.content)
|
309
|
+
hasSanityDep = !!(pkg.dependencies?.sanity || pkg.devDependencies?.sanity)
|
310
|
+
} catch (err) {
|
311
|
+
throw new Error(`Invalid package.json file in ${packagePath || 'root'}`)
|
312
|
+
}
|
313
|
+
|
314
|
+
const hasSanityConfig = fileChecks.some(
|
315
|
+
(f) => f.exists && (f.file === 'sanity.config.ts' || f.file === 'sanity.config.js'),
|
316
|
+
)
|
317
|
+
|
318
|
+
const hasSanityCli = fileChecks.some(
|
319
|
+
(f) => f.exists && (f.file === 'sanity.cli.ts' || f.file === 'sanity.cli.js'),
|
320
|
+
)
|
321
|
+
|
322
|
+
const envFile = fileChecks.find(
|
323
|
+
(f) => f.exists && ENV_TEMPLATE_FILES.includes(f.file as (typeof ENV_TEMPLATE_FILES)[number]),
|
324
|
+
)
|
325
|
+
if (envFile) {
|
326
|
+
const envContent = envFile.content
|
327
|
+
const hasProjectId = envContent.match(ENV_VAR.PROJECT_ID)
|
328
|
+
const hasDataset = envContent.match(ENV_VAR.DATASET)
|
329
|
+
|
330
|
+
if (!hasProjectId || !hasDataset) {
|
331
|
+
const missing = []
|
332
|
+
if (!hasProjectId) missing.push('SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID')
|
333
|
+
if (!hasDataset) missing.push('SANITY_DATASET or SANITY_STUDIO_DATASET')
|
334
|
+
throw new Error(
|
335
|
+
`Environment template in ${
|
336
|
+
packagePath || 'repo'
|
337
|
+
} must include the following variables: ${missing.join(', ')}`,
|
338
|
+
)
|
339
|
+
}
|
340
|
+
}
|
341
|
+
|
342
|
+
return {
|
343
|
+
hasSanityConfig,
|
344
|
+
hasSanityCli,
|
345
|
+
hasEnvFile: Boolean(envFile),
|
346
|
+
hasSanityDep,
|
347
|
+
}
|
348
|
+
}
|
349
|
+
|
350
|
+
/**
|
351
|
+
* Validates a GitHub repository template against required criteria.
|
352
|
+
* Supports both monorepo and single-package repositories.
|
353
|
+
*
|
354
|
+
* For monorepos:
|
355
|
+
* - Each package must have a valid package.json
|
356
|
+
* - At least one package must include 'sanity' in dependencies or devDependencies
|
357
|
+
* - At least one package must have sanity.config.js/ts and sanity.cli.js/ts
|
358
|
+
* - Each package must have a .env.template, .env.example, or .env.local.example
|
359
|
+
*
|
360
|
+
* For single-package repositories:
|
361
|
+
* - Must have a valid package.json with 'sanity' dependency
|
362
|
+
* - Must have sanity.config.js/ts and sanity.cli.js/ts
|
363
|
+
* - Must have .env.template, .env.example, or .env.local.example
|
364
|
+
*
|
365
|
+
* Environment files must include:
|
366
|
+
* - SANITY_PROJECT_ID or SANITY_STUDIO_PROJECT_ID variable
|
367
|
+
* - SANITY_DATASET or SANITY_STUDIO_DATASET variable
|
368
|
+
*
|
369
|
+
* @throws Error if validation fails with specific reason
|
370
|
+
*/
|
371
|
+
export async function validateRemoteTemplate(
|
372
|
+
repoInfo: RepoInfo,
|
373
|
+
packages: string[] = [''],
|
374
|
+
bearerToken?: string,
|
375
|
+
): Promise<void> {
|
376
|
+
const {username, name, branch, filePath} = repoInfo
|
377
|
+
const baseUrl = `https://raw.githubusercontent.com/${username}/${name}/${branch}/${filePath}`
|
378
|
+
|
379
|
+
const headers: Record<string, string> = {}
|
380
|
+
if (bearerToken) {
|
381
|
+
headers.Authorization = `Bearer ${bearerToken}`
|
382
|
+
}
|
383
|
+
|
384
|
+
const validations = await Promise.all(
|
385
|
+
packages.map((pkg) => validatePackage(baseUrl, pkg, headers)),
|
386
|
+
)
|
387
|
+
|
388
|
+
const hasSanityDep = validations.some((v) => v.hasSanityDep)
|
389
|
+
if (!hasSanityDep) {
|
390
|
+
throw new Error('At least one package must include "sanity" as a dependency in package.json')
|
391
|
+
}
|
392
|
+
|
393
|
+
const hasSanityConfig = validations.some((v) => v.hasSanityConfig)
|
394
|
+
if (!hasSanityConfig) {
|
395
|
+
throw new Error('At least one package must include a sanity.config.js or sanity.config.ts file')
|
396
|
+
}
|
397
|
+
|
398
|
+
const hasSanityCli = validations.some((v) => v.hasSanityCli)
|
399
|
+
if (!hasSanityCli) {
|
400
|
+
throw new Error('At least one package must include a sanity.cli.js or sanity.cli.ts file')
|
401
|
+
}
|
402
|
+
|
403
|
+
const missingEnvPackages = packages.filter((pkg, i) => !validations[i].hasEnvFile)
|
404
|
+
if (missingEnvPackages.length > 0) {
|
405
|
+
throw new Error(
|
406
|
+
`The following packages are missing .env.template, .env.example, or .env.local.example files: ${missingEnvPackages.join(
|
407
|
+
', ',
|
408
|
+
)}`,
|
409
|
+
)
|
410
|
+
}
|
411
|
+
}
|
412
|
+
|
413
|
+
export async function isNextJsTemplate(root: string): Promise<boolean> {
|
414
|
+
try {
|
415
|
+
const packageJson = await readFile(join(root, 'package.json'), 'utf8')
|
416
|
+
const pkg = JSON.parse(packageJson)
|
417
|
+
return !!(pkg.dependencies?.next || pkg.devDependencies?.next)
|
418
|
+
} catch {
|
419
|
+
return false
|
420
|
+
}
|
421
|
+
}
|
422
|
+
|
423
|
+
export async function applyEnvVariables(
|
424
|
+
root: string,
|
425
|
+
envData: EnvData,
|
426
|
+
targetName = '.env',
|
427
|
+
): Promise<void> {
|
428
|
+
const templatePath = await Promise.any(
|
429
|
+
ENV_TEMPLATE_FILES.map(async (file) => {
|
430
|
+
await access(join(root, file))
|
431
|
+
return file
|
432
|
+
}),
|
433
|
+
).catch(() => {
|
434
|
+
throw new Error('Could not find .env.template, .env.example or .env.local.example file')
|
435
|
+
})
|
436
|
+
|
437
|
+
try {
|
438
|
+
const templateContent = await readFile(join(root, templatePath), 'utf8')
|
439
|
+
const {projectId, dataset, readToken = ''} = envData
|
440
|
+
|
441
|
+
const findAndReplaceVariable = (
|
442
|
+
content: string,
|
443
|
+
varRegex: RegExp | string,
|
444
|
+
value: string,
|
445
|
+
useQuotes: boolean,
|
446
|
+
) => {
|
447
|
+
const pattern = varRegex instanceof RegExp ? varRegex : new RegExp(`${varRegex}=.*$`, 'm')
|
448
|
+
const match = content.match(pattern)
|
449
|
+
if (!match) return content
|
450
|
+
|
451
|
+
const varName = match[0].split('=')[0]
|
452
|
+
return content.replace(
|
453
|
+
new RegExp(`${varName}=.*$`, 'm'),
|
454
|
+
`${varName}=${useQuotes ? `"${value}"` : value}`,
|
455
|
+
)
|
456
|
+
}
|
457
|
+
|
458
|
+
let envContent = templateContent
|
459
|
+
const vars = [
|
460
|
+
{pattern: ENV_VAR.PROJECT_ID, value: projectId},
|
461
|
+
{pattern: ENV_VAR.DATASET, value: dataset},
|
462
|
+
{pattern: ENV_VAR.READ_TOKEN, value: readToken},
|
463
|
+
]
|
464
|
+
const useQuotes = templateContent.includes('="')
|
465
|
+
|
466
|
+
for (const {pattern, value} of vars) {
|
467
|
+
envContent = findAndReplaceVariable(envContent, pattern, value, useQuotes)
|
468
|
+
}
|
469
|
+
|
470
|
+
await writeFile(join(root, targetName), envContent)
|
471
|
+
} catch (err) {
|
472
|
+
throw new Error(
|
473
|
+
'Failed to set environment variables. This could be due to file permissions or the .env file format. See https://www.sanity.io/docs/environment-variables for details on environment variable setup.',
|
474
|
+
)
|
475
|
+
}
|
476
|
+
}
|
477
|
+
|
478
|
+
export async function tryApplyPackageName(root: string, name: string): Promise<void> {
|
479
|
+
try {
|
480
|
+
const packageJson = await readFile(join(root, 'package.json'), 'utf8')
|
481
|
+
const pkg: PackageJson = JSON.parse(packageJson)
|
482
|
+
pkg.name = name
|
483
|
+
|
484
|
+
await writeFile(join(root, 'package.json'), JSON.stringify(pkg, null, 2))
|
485
|
+
} catch (err) {
|
486
|
+
// noop
|
487
|
+
}
|
488
|
+
}
|
489
|
+
|
490
|
+
export async function generateSanityApiReadToken(
|
491
|
+
projectId: string,
|
492
|
+
apiClient: CliApiClient,
|
493
|
+
): Promise<string> {
|
494
|
+
const response = await apiClient({requireProject: false, requireUser: true})
|
495
|
+
.config({apiVersion: 'v2021-06-07'})
|
496
|
+
.request<{key: string}>({
|
497
|
+
uri: `/projects/${projectId}/tokens`,
|
498
|
+
method: 'POST',
|
499
|
+
body: {
|
500
|
+
label: `API Read Token (${Date.now()})`,
|
501
|
+
roleName: 'viewer',
|
502
|
+
},
|
503
|
+
})
|
504
|
+
return response.key
|
505
|
+
}
|