orizu 0.0.2

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/src/index.ts ADDED
@@ -0,0 +1,1272 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from 'crypto'
3
+ import { basename } from 'path'
4
+ import { createServer } from 'http'
5
+ import { readFileSync, writeFileSync } from 'fs'
6
+ import { spawn } from 'child_process'
7
+ import { createInterface } from 'readline/promises'
8
+ import { stdin as input, stdout as output } from 'process'
9
+ import { clearCredentials, loadCredentials, saveCredentials } from './credentials.js'
10
+ import { parseDatasetFile } from './file-parser.js'
11
+ import { authedFetch, getBaseUrl } from './http.js'
12
+ import { LoginResponse } from './types.js'
13
+
14
+ interface Team {
15
+ id: string
16
+ name: string
17
+ slug: string
18
+ role: string
19
+ }
20
+
21
+ interface Project {
22
+ id: string
23
+ name: string
24
+ slug: string
25
+ teamId: string
26
+ teamName: string
27
+ teamSlug: string
28
+ role: string
29
+ }
30
+
31
+ interface Task {
32
+ id: string
33
+ title: string
34
+ status: string
35
+ createdAt: string
36
+ projectName?: string
37
+ projectSlug?: string
38
+ teamName?: string
39
+ teamSlug?: string
40
+ }
41
+
42
+ interface AppSummary {
43
+ id: string
44
+ name: string
45
+ currentVersionNum: number
46
+ createdAt: string
47
+ teamSlug: string
48
+ teamName: string
49
+ projectSlug: string
50
+ projectName: string
51
+ }
52
+
53
+ interface TeamMember {
54
+ id: string
55
+ user_id: string | null
56
+ email: string
57
+ role: string
58
+ joined_at: string
59
+ }
60
+
61
+ interface TaskStatusPayload {
62
+ task: {
63
+ id: string
64
+ title: string
65
+ status: string
66
+ createdAt: string
67
+ teamSlug: string
68
+ teamName: string
69
+ projectSlug: string
70
+ projectName: string
71
+ datasetRowCount: number
72
+ requiredAssignmentsPerRow: number
73
+ totalRequiredAssignments: number
74
+ counts: {
75
+ completed: number
76
+ inProgress: number
77
+ pending: number
78
+ skipped: number
79
+ }
80
+ progressPercentage: number
81
+ assignees: Array<{
82
+ assigneeId: string
83
+ email: string
84
+ completed: number
85
+ inProgress: number
86
+ pending: number
87
+ skipped: number
88
+ total: number
89
+ }>
90
+ }
91
+ }
92
+
93
+ function printUsage() {
94
+ console.log(`orizu commands:\n\n orizu login\n orizu logout\n orizu whoami\n orizu teams list\n orizu teams create [--name <name>]\n orizu teams members list [--team <teamSlug>]\n orizu teams members add --email <email> [--team <teamSlug>]\n orizu teams members remove --email <email> [--team <teamSlug>]\n orizu teams members role --team <teamSlug> --email <email> --role <admin|member>\n orizu projects list [--team <teamSlug>]\n orizu projects create --name <name> [--team <teamSlug>]\n orizu apps list [--project <team/project>]\n orizu apps create --project <team/project> --name <name> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--instructions <text>] [--labels-per-item <n>]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
95
+ }
96
+
97
+ function getArg(name: string): string | null {
98
+ const index = process.argv.indexOf(name)
99
+ if (index === -1 || index + 1 >= process.argv.length) {
100
+ return null
101
+ }
102
+
103
+ return process.argv[index + 1]
104
+ }
105
+
106
+ function isInteractiveTerminal() {
107
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
108
+ }
109
+
110
+ function hasArg(name: string): boolean {
111
+ return process.argv.includes(name)
112
+ }
113
+
114
+ function expandHomePath(path: string): string {
115
+ if (path.startsWith('~/')) {
116
+ const home = process.env.HOME || ''
117
+ return `${home}/${path.slice(2)}`
118
+ }
119
+
120
+ return path
121
+ }
122
+
123
+ function createCodeVerifier(): string {
124
+ return randomBytes(32).toString('base64url')
125
+ }
126
+
127
+ function createCodeChallenge(verifier: string): string {
128
+ return createHash('sha256').update(verifier).digest('base64url')
129
+ }
130
+
131
+ function openInBrowser(url: string) {
132
+ const platform = process.platform
133
+ if (platform === 'darwin') {
134
+ spawn('open', [url], {
135
+ detached: true,
136
+ stdio: 'ignore',
137
+ }).unref()
138
+ return
139
+ }
140
+
141
+ if (platform === 'win32') {
142
+ spawn('cmd', ['/c', 'start', '', url], {
143
+ detached: true,
144
+ stdio: 'ignore',
145
+ windowsHide: true,
146
+ }).unref()
147
+ return
148
+ }
149
+
150
+ spawn('xdg-open', [url], {
151
+ detached: true,
152
+ stdio: 'ignore',
153
+ }).unref()
154
+ }
155
+
156
+ function formatTerminalLink(url: string): string {
157
+ if (!isInteractiveTerminal()) {
158
+ return url
159
+ }
160
+
161
+ return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
162
+ }
163
+
164
+ async function parseJsonResponse<T>(response: Response, context: string): Promise<T> {
165
+ const contentType = response.headers.get('content-type') || ''
166
+ const rawBody = await response.text()
167
+
168
+ if (!contentType.includes('application/json')) {
169
+ throw new Error(
170
+ `${context} returned non-JSON response (status ${response.status}). ` +
171
+ `Body preview: ${rawBody.slice(0, 180)}`
172
+ )
173
+ }
174
+
175
+ try {
176
+ return JSON.parse(rawBody) as T
177
+ } catch {
178
+ throw new Error(
179
+ `${context} returned invalid JSON (status ${response.status}). ` +
180
+ `Body preview: ${rawBody.slice(0, 180)}`
181
+ )
182
+ }
183
+ }
184
+
185
+ async function promptSelect<T>(
186
+ title: string,
187
+ items: T[],
188
+ label: (item: T, index: number) => string,
189
+ options?: { forcePrompt?: boolean }
190
+ ): Promise<T> {
191
+ if (items.length === 0) {
192
+ throw new Error(`No options available for ${title.toLowerCase()}`)
193
+ }
194
+
195
+ if (!isInteractiveTerminal()) {
196
+ throw new Error(
197
+ `${title} selection requires interactive terminal. Provide flags explicitly instead.`
198
+ )
199
+ }
200
+
201
+ if (items.length === 1 && !options?.forcePrompt) {
202
+ return items[0]
203
+ }
204
+
205
+ console.log(`\n${title}`)
206
+ items.forEach((item, index) => {
207
+ console.log(` ${index + 1}. ${label(item, index)}`)
208
+ })
209
+
210
+ const rl = createInterface({ input, output })
211
+ try {
212
+ while (true) {
213
+ const answer = (await rl.question('Choose a number: ')).trim()
214
+ const chosenIndex = Number(answer)
215
+ if (Number.isInteger(chosenIndex) && chosenIndex >= 1 && chosenIndex <= items.length) {
216
+ return items[chosenIndex - 1]
217
+ }
218
+
219
+ console.log('Invalid selection. Enter a valid number from the list.')
220
+ }
221
+ } finally {
222
+ rl.close()
223
+ }
224
+ }
225
+
226
+ async function fetchTeams(): Promise<Team[]> {
227
+ const response = await authedFetch('/api/cli/teams')
228
+ if (!response.ok) {
229
+ throw new Error(`Failed to fetch teams: ${await response.text()}`)
230
+ }
231
+
232
+ const data = await parseJsonResponse<{ teams: Team[] }>(response, 'Teams list')
233
+ return data.teams
234
+ }
235
+
236
+ async function fetchProjects(teamSlug?: string): Promise<Project[]> {
237
+ const query = teamSlug ? `?teamSlug=${encodeURIComponent(teamSlug)}` : ''
238
+ const response = await authedFetch(`/api/cli/projects${query}`)
239
+ if (!response.ok) {
240
+ throw new Error(`Failed to fetch projects: ${await response.text()}`)
241
+ }
242
+
243
+ const data = await parseJsonResponse<{ projects: Project[] }>(response, 'Projects list')
244
+ return data.projects
245
+ }
246
+
247
+ async function fetchTasks(project?: string): Promise<Task[]> {
248
+ const query = project ? `?project=${encodeURIComponent(project)}` : ''
249
+ const response = await authedFetch(`/api/cli/tasks${query}`)
250
+ if (!response.ok) {
251
+ throw new Error(`Failed to fetch tasks: ${await response.text()}`)
252
+ }
253
+
254
+ const data = await parseJsonResponse<{ tasks: Task[] }>(response, 'Tasks list')
255
+ return data.tasks
256
+ }
257
+
258
+ async function fetchApps(project: string): Promise<AppSummary[]> {
259
+ const response = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(project)}`)
260
+ if (!response.ok) {
261
+ throw new Error(`Failed to fetch apps: ${await response.text()}`)
262
+ }
263
+
264
+ const data = await parseJsonResponse<{ apps: AppSummary[] }>(response, 'Apps list')
265
+ return data.apps
266
+ }
267
+
268
+ async function fetchTeamMembers(teamSlug: string): Promise<TeamMember[]> {
269
+ const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`)
270
+ if (!response.ok) {
271
+ throw new Error(`Failed to fetch team members: ${await response.text()}`)
272
+ }
273
+
274
+ const data = await parseJsonResponse<{ members: TeamMember[] }>(response, 'Team members list')
275
+ return data.members
276
+ }
277
+
278
+ async function resolveProjectSlug(projectArg: string | null): Promise<string> {
279
+ const teams = await fetchTeams()
280
+
281
+ if (teams.length === 0) {
282
+ throw new Error('No accessible teams found for this user.')
283
+ }
284
+
285
+ if (!projectArg) {
286
+ const team = await promptSelect(
287
+ 'Select a team',
288
+ teams,
289
+ teamOption => `${teamOption.name} (${teamOption.slug})`,
290
+ { forcePrompt: true }
291
+ )
292
+
293
+ const projects = await fetchProjects(team.slug)
294
+ const project = await promptSelect(
295
+ `Select a project in ${team.slug}`,
296
+ projects,
297
+ projectOption => `${projectOption.name} (${projectOption.teamSlug}/${projectOption.slug})`,
298
+ { forcePrompt: true }
299
+ )
300
+
301
+ return `${project.teamSlug}/${project.slug}`
302
+ }
303
+
304
+ const segments = projectArg.split('/')
305
+ if (segments.length !== 2 || !segments[0] || !segments[1]) {
306
+ throw new Error('Project must be in format teamSlug/projectSlug')
307
+ }
308
+ const [teamSlug, projectSlug] = segments
309
+
310
+ const matchedTeam = teams.find(team => team.slug === teamSlug)
311
+ if (!matchedTeam) {
312
+ console.error(`Team '${teamSlug}' not found in your accessible teams.`)
313
+ const selectedTeam = await promptSelect(
314
+ 'Select a team',
315
+ teams,
316
+ team => `${team.name} (${team.slug})`
317
+ )
318
+
319
+ const projects = await fetchProjects(selectedTeam.slug)
320
+ const selectedProject = await promptSelect(
321
+ `Select a project in ${selectedTeam.slug}`,
322
+ projects,
323
+ project => `${project.name} (${project.teamSlug}/${project.slug})`
324
+ )
325
+
326
+ return `${selectedProject.teamSlug}/${selectedProject.slug}`
327
+ }
328
+
329
+ const projects = await fetchProjects(teamSlug)
330
+ const matchedProject = projects.find(project => project.slug === projectSlug)
331
+
332
+ if (!matchedProject) {
333
+ console.error(`Project '${projectSlug}' not found in team '${teamSlug}'.`)
334
+ const selectedProject = await promptSelect(
335
+ `Select a project in ${teamSlug}`,
336
+ projects,
337
+ project => `${project.name} (${project.teamSlug}/${project.slug})`
338
+ )
339
+
340
+ return `${selectedProject.teamSlug}/${selectedProject.slug}`
341
+ }
342
+
343
+ return `${teamSlug}/${projectSlug}`
344
+ }
345
+
346
+ async function selectTaskIdInteractively(): Promise<string> {
347
+ const team = await promptSelect(
348
+ 'Select a team',
349
+ await fetchTeams(),
350
+ item => `${item.name} (${item.slug})`,
351
+ { forcePrompt: true }
352
+ )
353
+
354
+ const project = await promptSelect(
355
+ `Select a project in ${team.slug}`,
356
+ await fetchProjects(team.slug),
357
+ item => `${item.name} (${item.teamSlug}/${item.slug})`,
358
+ { forcePrompt: true }
359
+ )
360
+
361
+ const tasks = await fetchTasks(`${project.teamSlug}/${project.slug}`)
362
+ const task = await promptSelect(
363
+ `Select a task in ${project.teamSlug}/${project.slug}`,
364
+ tasks,
365
+ item => `${item.title} [${item.status}] (${item.id})`,
366
+ { forcePrompt: true }
367
+ )
368
+
369
+ return task.id
370
+ }
371
+
372
+ async function selectAppIdInteractively(projectArg: string | null): Promise<{ appId: string; project: string }> {
373
+ let project = projectArg
374
+ if (!project) {
375
+ project = await resolveProjectSlug(null)
376
+ }
377
+
378
+ const apps = await fetchApps(project)
379
+ const app = await promptSelect(
380
+ `Select an app in ${project}`,
381
+ apps,
382
+ item => `${item.name} (id=${item.id}, v${item.currentVersionNum})`,
383
+ { forcePrompt: true }
384
+ )
385
+
386
+ return {
387
+ appId: app.id,
388
+ project,
389
+ }
390
+ }
391
+
392
+ function printTeams(teams: Team[]) {
393
+ if (teams.length === 0) {
394
+ console.log('No teams found.')
395
+ return
396
+ }
397
+
398
+ const rows = teams.map(team => ({
399
+ slug: team.slug,
400
+ name: team.name || '-',
401
+ role: team.role || '-',
402
+ }))
403
+
404
+ const slugWidth = Math.max('TEAM SLUG'.length, ...rows.map(row => row.slug.length))
405
+ const nameWidth = Math.max('TEAM NAME'.length, ...rows.map(row => row.name.length))
406
+ const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
407
+
408
+ console.log(
409
+ `${'TEAM SLUG'.padEnd(slugWidth)} ${'TEAM NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
410
+ )
411
+ console.log(
412
+ `${'-'.repeat(slugWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
413
+ )
414
+
415
+ rows.forEach(row => {
416
+ console.log(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`)
417
+ })
418
+ }
419
+
420
+ function printProjects(projects: Project[]) {
421
+ if (projects.length === 0) {
422
+ console.log('No projects found.')
423
+ return
424
+ }
425
+
426
+ const rows = projects.map(project => ({
427
+ project: `${project.teamSlug}/${project.slug}`,
428
+ name: project.name || '-',
429
+ role: project.role || '-',
430
+ }))
431
+
432
+ const projectWidth = Math.max('TEAM/PROJECT'.length, ...rows.map(row => row.project.length))
433
+ const nameWidth = Math.max('PROJECT NAME'.length, ...rows.map(row => row.name.length))
434
+ const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
435
+
436
+ console.log(
437
+ `${'TEAM/PROJECT'.padEnd(projectWidth)} ${'PROJECT NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
438
+ )
439
+ console.log(
440
+ `${'-'.repeat(projectWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
441
+ )
442
+
443
+ rows.forEach(row => {
444
+ console.log(
445
+ `${row.project.padEnd(projectWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`
446
+ )
447
+ })
448
+ }
449
+
450
+ function printTasks(tasks: Task[]) {
451
+ if (tasks.length === 0) {
452
+ console.log('No tasks found.')
453
+ return
454
+ }
455
+
456
+ const rows = tasks.map(task => ({
457
+ id: task.id,
458
+ name: task.title || '-',
459
+ status: task.status || '-',
460
+ project: task.teamSlug && task.projectSlug
461
+ ? `${task.teamSlug}/${task.projectSlug}`
462
+ : 'unknown-project',
463
+ }))
464
+
465
+ const idWidth = Math.max('TASK ID'.length, ...rows.map(row => row.id.length))
466
+ const nameWidth = Math.max('TASK NAME'.length, ...rows.map(row => row.name.length))
467
+ const statusWidth = Math.max('STATUS'.length, ...rows.map(row => row.status.length))
468
+
469
+ console.log(
470
+ `${'TASK ID'.padEnd(idWidth)} ${'TASK NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} TEAM/PROJECT`
471
+ )
472
+ console.log(
473
+ `${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(statusWidth)} ------------`
474
+ )
475
+
476
+ rows.forEach(row => {
477
+ console.log(
478
+ `${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.project}`
479
+ )
480
+ })
481
+ }
482
+
483
+ function printApps(apps: AppSummary[]) {
484
+ if (apps.length === 0) {
485
+ console.log('No apps found.')
486
+ return
487
+ }
488
+
489
+ const rows = apps.map(app => ({
490
+ id: app.id,
491
+ name: app.name || '-',
492
+ version: `v${app.currentVersionNum || 1}`,
493
+ }))
494
+
495
+ const idWidth = Math.max('APP ID'.length, ...rows.map(row => row.id.length))
496
+ const nameWidth = Math.max('APP NAME'.length, ...rows.map(row => row.name.length))
497
+ const versionWidth = Math.max('VERSION'.length, ...rows.map(row => row.version.length))
498
+
499
+ console.log(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`)
500
+ console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`)
501
+
502
+ rows.forEach(row => {
503
+ console.log(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`)
504
+ })
505
+ }
506
+
507
+ function printTeamMembers(members: TeamMember[]) {
508
+ if (members.length === 0) {
509
+ console.log('No team members found.')
510
+ return
511
+ }
512
+
513
+ const rows = members.map(member => ({
514
+ id: member.id,
515
+ email: member.email || '-',
516
+ role: member.role || '-',
517
+ }))
518
+
519
+ const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
520
+ const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
521
+ const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
522
+
523
+ console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`)
524
+ console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`)
525
+ rows.forEach(row => {
526
+ console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`)
527
+ })
528
+ }
529
+
530
+ function printTaskStatusSummary(data: TaskStatusPayload) {
531
+ const task = data.task
532
+ console.log(`Task: ${task.title} (${task.id})`)
533
+ console.log(`Status: ${task.status}`)
534
+ console.log(`Project: ${task.teamSlug}/${task.projectSlug}`)
535
+ console.log(`Progress: ${task.progressPercentage}%`)
536
+ console.log(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`)
537
+ console.log(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`)
538
+
539
+ if (task.assignees.length > 0) {
540
+ console.log('\nAssignees')
541
+ task.assignees.forEach(assignee => {
542
+ console.log(
543
+ ` ${assignee.email}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`
544
+ )
545
+ })
546
+ }
547
+ }
548
+
549
+ async function login() {
550
+ const baseUrl = getBaseUrl()
551
+ const codeVerifier = createCodeVerifier()
552
+ const codeChallenge = createCodeChallenge(codeVerifier)
553
+
554
+ const callbackCode = await new Promise<string>((resolve, reject) => {
555
+ const server = createServer((request, response) => {
556
+ try {
557
+ const url = new URL(request.url || '/', 'http://127.0.0.1:43123')
558
+ const code = url.searchParams.get('code')
559
+
560
+ if (!code) {
561
+ response.statusCode = 400
562
+ response.end('Missing code')
563
+ return
564
+ }
565
+
566
+ response.statusCode = 200
567
+ response.setHeader('content-type', 'text/html')
568
+ response.end('<html><body><h3>CLI login complete. You can close this tab.</h3></body></html>')
569
+
570
+ server.close()
571
+ resolve(code)
572
+ } catch (error) {
573
+ server.close()
574
+ reject(error)
575
+ }
576
+ })
577
+
578
+ server.on('error', reject)
579
+
580
+ server.listen(43123, '127.0.0.1', async () => {
581
+ try {
582
+ const redirectUri = 'http://127.0.0.1:43123/callback'
583
+ const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
584
+ method: 'POST',
585
+ headers: { 'Content-Type': 'application/json' },
586
+ body: JSON.stringify({ codeChallenge, redirectUri }),
587
+ })
588
+
589
+ if (!response.ok) {
590
+ const text = await response.text()
591
+ server.close()
592
+ reject(new Error(`Failed to start login: ${text}`))
593
+ return
594
+ }
595
+
596
+ const { authorizeUrl } = await parseJsonResponse<{ authorizeUrl: string }>(
597
+ response,
598
+ 'CLI auth start'
599
+ )
600
+ console.log(`Opening browser for login: ${authorizeUrl}`)
601
+ openInBrowser(authorizeUrl)
602
+ } catch (error) {
603
+ server.close()
604
+ reject(error)
605
+ }
606
+ })
607
+ })
608
+
609
+ const exchangeResponse = await fetch(`${baseUrl}/api/cli/auth/exchange`, {
610
+ method: 'POST',
611
+ headers: { 'Content-Type': 'application/json' },
612
+ body: JSON.stringify({ code: callbackCode, codeVerifier }),
613
+ })
614
+
615
+ if (!exchangeResponse.ok) {
616
+ const text = await exchangeResponse.text()
617
+ throw new Error(`Failed to exchange auth code: ${text}`)
618
+ }
619
+
620
+ const loginData = await parseJsonResponse<LoginResponse>(exchangeResponse, 'CLI auth exchange')
621
+ saveCredentials({
622
+ accessToken: loginData.accessToken,
623
+ refreshToken: loginData.refreshToken,
624
+ expiresAt: loginData.expiresAt,
625
+ baseUrl,
626
+ })
627
+
628
+ console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`)
629
+ }
630
+
631
+ async function whoami() {
632
+ const response = await authedFetch('/api/cli/auth/whoami')
633
+ if (!response.ok) {
634
+ throw new Error(`whoami failed: ${await response.text()}`)
635
+ }
636
+
637
+ const data = await response.json() as { user: { id: string; email: string | null } }
638
+ console.log(data.user.email ?? data.user.id)
639
+ }
640
+
641
+ async function logout() {
642
+ const credentials = loadCredentials()
643
+ if (!credentials) {
644
+ console.log('Already logged out.')
645
+ return
646
+ }
647
+
648
+ await fetch(`${credentials.baseUrl}/api/cli/auth/logout`, {
649
+ method: 'POST',
650
+ headers: {
651
+ Authorization: `Bearer ${credentials.accessToken}`,
652
+ },
653
+ }).catch(() => undefined)
654
+
655
+ clearCredentials()
656
+ console.log('Logged out.')
657
+ }
658
+
659
+ async function listTeams() {
660
+ printTeams(await fetchTeams())
661
+ }
662
+
663
+ async function resolveTeamSlug(teamSlugArg: string | null): Promise<string> {
664
+ if (teamSlugArg) {
665
+ return teamSlugArg
666
+ }
667
+
668
+ const team = await promptSelect(
669
+ 'Select a team',
670
+ await fetchTeams(),
671
+ item => `${item.name} (${item.slug})`,
672
+ { forcePrompt: true }
673
+ )
674
+
675
+ return team.slug
676
+ }
677
+
678
+ async function createTeam() {
679
+ let name = getArg('--name')
680
+
681
+ if (!name && isInteractiveTerminal()) {
682
+ const rl = createInterface({ input, output })
683
+ try {
684
+ name = (await rl.question('Team name: ')).trim()
685
+ } finally {
686
+ rl.close()
687
+ }
688
+ }
689
+
690
+ if (!name) {
691
+ throw new Error('Usage: orizu teams create --name <name>')
692
+ }
693
+
694
+ const response = await authedFetch('/api/cli/teams', {
695
+ method: 'POST',
696
+ headers: { 'Content-Type': 'application/json' },
697
+ body: JSON.stringify({ name }),
698
+ })
699
+
700
+ if (!response.ok) {
701
+ throw new Error(`Failed to create team: ${await response.text()}`)
702
+ }
703
+
704
+ const data = await parseJsonResponse<{ team: Team }>(response, 'Team create')
705
+ console.log(`Created team: ${data.team.name} (${data.team.slug})`)
706
+ }
707
+
708
+ async function listProjects() {
709
+ const teamSlug = getArg('--team')
710
+ printProjects(await fetchProjects(teamSlug || undefined))
711
+ }
712
+
713
+ async function createProject() {
714
+ const name = getArg('--name')
715
+ let teamSlug = getArg('--team')
716
+
717
+ if (!name) {
718
+ throw new Error('Usage: orizu projects create --name <name> [--team <teamSlug>]')
719
+ }
720
+
721
+ if (!teamSlug) {
722
+ const team = await promptSelect(
723
+ 'Select a team',
724
+ await fetchTeams(),
725
+ item => `${item.name} (${item.slug})`,
726
+ { forcePrompt: true }
727
+ )
728
+ teamSlug = team.slug
729
+ }
730
+
731
+ const response = await authedFetch('/api/cli/projects', {
732
+ method: 'POST',
733
+ headers: { 'Content-Type': 'application/json' },
734
+ body: JSON.stringify({ teamSlug, name }),
735
+ })
736
+
737
+ if (!response.ok) {
738
+ throw new Error(`Failed to create project: ${await response.text()}`)
739
+ }
740
+
741
+ const data = await parseJsonResponse<{
742
+ project: { id: string; name: string; slug: string; teamSlug: string }
743
+ }>(response, 'Project create')
744
+ console.log(`Created project ${data.project.teamSlug}/${data.project.slug}`)
745
+ }
746
+
747
+ async function listTasks() {
748
+ const project = getArg('--project')
749
+ printTasks(await fetchTasks(project || undefined))
750
+ }
751
+
752
+ async function listApps() {
753
+ const project = getArg('--project') || await resolveProjectSlug(null)
754
+ printApps(await fetchApps(project))
755
+ }
756
+
757
+ function readSourceFile(pathArg: string): string {
758
+ const expandedPath = expandHomePath(pathArg)
759
+ try {
760
+ return readFileSync(expandedPath, 'utf-8')
761
+ } catch (error: any) {
762
+ if (error?.code === 'ENOENT') {
763
+ throw new Error(`File not found: ${expandedPath}`)
764
+ }
765
+ throw new Error(`Failed to read file '${expandedPath}': ${error?.message || String(error)}`)
766
+ }
767
+ }
768
+
769
+ function readJsonFile(pathArg: string): Record<string, unknown> {
770
+ const raw = readSourceFile(pathArg)
771
+ try {
772
+ const parsed = JSON.parse(raw) as unknown
773
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
774
+ throw new Error('JSON root must be an object')
775
+ }
776
+
777
+ return parsed as Record<string, unknown>
778
+ } catch (error: any) {
779
+ throw new Error(`Invalid JSON file '${pathArg}': ${error?.message || String(error)}`)
780
+ }
781
+ }
782
+
783
+ async function createAppFromFile() {
784
+ const project = getArg('--project')
785
+ const name = getArg('--name')
786
+ const filePath = getArg('--file')
787
+ const inputSchemaPath = getArg('--input-schema')
788
+ const outputSchemaPath = getArg('--output-schema')
789
+ const component = getArg('--component') || undefined
790
+
791
+ if (!project || !name || !filePath || !inputSchemaPath || !outputSchemaPath) {
792
+ throw new Error('Usage: orizu apps create --project <team/project> --name <name> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
793
+ }
794
+
795
+ const sourceCode = readSourceFile(filePath)
796
+ const inputJsonSchema = readJsonFile(inputSchemaPath)
797
+ const outputJsonSchema = readJsonFile(outputSchemaPath)
798
+ const response = await authedFetch('/api/cli/apps/create-from-file', {
799
+ method: 'POST',
800
+ headers: { 'Content-Type': 'application/json' },
801
+ body: JSON.stringify({
802
+ projectSlug: project,
803
+ name,
804
+ sourceCode,
805
+ componentName: component,
806
+ inputJsonSchema,
807
+ outputJsonSchema,
808
+ }),
809
+ })
810
+
811
+ if (!response.ok) {
812
+ throw new Error(`Failed to create app: ${await response.text()}`)
813
+ }
814
+
815
+ const data = await parseJsonResponse<{
816
+ app: { id: string; name: string; versionNum: number; componentName?: string }
817
+ warnings?: string[]
818
+ }>(response, 'App create')
819
+ console.log(`Created app ${data.app.name} (${data.app.id}) v${data.app.versionNum}`)
820
+ if (data.warnings?.length) {
821
+ console.log(`Warnings: ${data.warnings.join('; ')}`)
822
+ }
823
+ }
824
+
825
+ async function updateAppFromFile() {
826
+ const filePath = getArg('--file')
827
+ const inputSchemaPath = getArg('--input-schema')
828
+ const outputSchemaPath = getArg('--output-schema')
829
+ const component = getArg('--component') || undefined
830
+ let appId = getArg('--app')
831
+ const project = getArg('--project')
832
+
833
+ if (!filePath || !inputSchemaPath || !outputSchemaPath) {
834
+ throw new Error('Usage: orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
835
+ }
836
+
837
+ if (!appId) {
838
+ const selected = await selectAppIdInteractively(project)
839
+ appId = selected.appId
840
+ }
841
+
842
+ const sourceCode = readSourceFile(filePath)
843
+ const inputJsonSchema = readJsonFile(inputSchemaPath)
844
+ const outputJsonSchema = readJsonFile(outputSchemaPath)
845
+ const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/update-from-file`, {
846
+ method: 'POST',
847
+ headers: { 'Content-Type': 'application/json' },
848
+ body: JSON.stringify({
849
+ sourceCode,
850
+ componentName: component,
851
+ inputJsonSchema,
852
+ outputJsonSchema,
853
+ }),
854
+ })
855
+
856
+ if (!response.ok) {
857
+ throw new Error(`Failed to update app: ${await response.text()}`)
858
+ }
859
+
860
+ const data = await parseJsonResponse<{
861
+ app: { id: string; name: string; versionNum: number; componentName?: string }
862
+ warnings?: string[]
863
+ }>(response, 'App update')
864
+ console.log(`Updated app ${data.app.name} (${data.app.id}) to v${data.app.versionNum}`)
865
+ if (data.warnings?.length) {
866
+ console.log(`Warnings: ${data.warnings.join('; ')}`)
867
+ }
868
+ }
869
+
870
+ async function linkAppDataset() {
871
+ const datasetId = getArg('--dataset')
872
+ const project = getArg('--project')
873
+ let appId = getArg('--app')
874
+ const versionArg = getArg('--version')
875
+ const versionNum = versionArg ? Number(versionArg) : undefined
876
+
877
+ if (!datasetId) {
878
+ throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]')
879
+ }
880
+
881
+ if (!appId) {
882
+ const selected = await selectAppIdInteractively(project)
883
+ appId = selected.appId
884
+ }
885
+
886
+ const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/link-dataset`, {
887
+ method: 'POST',
888
+ headers: { 'Content-Type': 'application/json' },
889
+ body: JSON.stringify({
890
+ datasetId,
891
+ versionNum,
892
+ }),
893
+ })
894
+
895
+ if (!response.ok) {
896
+ throw new Error(`Failed to link dataset: ${await response.text()}`)
897
+ }
898
+
899
+ const data = await parseJsonResponse<{
900
+ app: { id: string; name: string }
901
+ linkedDataset: { id: string; name: string }
902
+ versionNum: number
903
+ }>(response, 'App link dataset')
904
+
905
+ console.log(
906
+ `Linked dataset ${data.linkedDataset.name} (${data.linkedDataset.id}) to app ${data.app.name} (${data.app.id}) version ${data.versionNum}`
907
+ )
908
+ }
909
+
910
+ function parseCommaSeparated(value: string | null): string[] {
911
+ if (!value) {
912
+ return []
913
+ }
914
+ return value
915
+ .split(',')
916
+ .map(item => item.trim())
917
+ .filter(Boolean)
918
+ }
919
+
920
+ async function createTask() {
921
+ const projectSlug = getArg('--project')
922
+ const datasetId = getArg('--dataset')
923
+ const appId = getArg('--app')
924
+ const title = getArg('--title')
925
+ const instructions = getArg('--instructions')
926
+ const labelsPerItemArg = getArg('--labels-per-item')
927
+ const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
928
+
929
+ if (!projectSlug || !datasetId || !appId || !title) {
930
+ throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--instructions <text>] [--labels-per-item <n>]')
931
+ }
932
+
933
+ const response = await authedFetch('/api/cli/tasks', {
934
+ method: 'POST',
935
+ headers: { 'Content-Type': 'application/json' },
936
+ body: JSON.stringify({
937
+ projectSlug,
938
+ datasetId,
939
+ appId,
940
+ title,
941
+ instructions,
942
+ requiredAssignmentsPerRow: labelsPerItem,
943
+ }),
944
+ })
945
+
946
+ if (!response.ok) {
947
+ throw new Error(`Failed to create task: ${await response.text()}`)
948
+ }
949
+
950
+ const data = await parseJsonResponse<{
951
+ task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
952
+ }>(response, 'Task create')
953
+ console.log(
954
+ `Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}`
955
+ )
956
+ }
957
+
958
+ async function assignTask() {
959
+ const taskId = getArg('--task')
960
+ const assignees = parseCommaSeparated(getArg('--assignees'))
961
+
962
+ if (!taskId || assignees.length === 0) {
963
+ throw new Error('Usage: orizu tasks assign --task <taskId> --assignees <userId1,userId2>')
964
+ }
965
+
966
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/assign`, {
967
+ method: 'POST',
968
+ headers: { 'Content-Type': 'application/json' },
969
+ body: JSON.stringify({ memberIds: assignees }),
970
+ })
971
+
972
+ if (!response.ok) {
973
+ throw new Error(`Failed to assign task: ${await response.text()}`)
974
+ }
975
+
976
+ const data = await parseJsonResponse<{ assignmentsCreated: number }>(response, 'Task assign')
977
+ console.log(`Created ${data.assignmentsCreated} assignments.`)
978
+ }
979
+
980
+ async function taskStatus() {
981
+ const taskId = getArg('--task')
982
+ if (!taskId) {
983
+ throw new Error('Usage: orizu tasks status --task <taskId> [--json]')
984
+ }
985
+
986
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`)
987
+ if (!response.ok) {
988
+ throw new Error(`Failed to fetch task status: ${await response.text()}`)
989
+ }
990
+
991
+ const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
992
+ if (hasArg('--json')) {
993
+ console.log(JSON.stringify(data, null, 2))
994
+ return
995
+ }
996
+
997
+ printTaskStatusSummary(data)
998
+ }
999
+
1000
+ async function listTeamMembers() {
1001
+ const teamSlug = await resolveTeamSlug(getArg('--team'))
1002
+
1003
+ printTeamMembers(await fetchTeamMembers(teamSlug))
1004
+ }
1005
+
1006
+ async function addTeamMember() {
1007
+ const teamSlug = await resolveTeamSlug(getArg('--team'))
1008
+ const email = getArg('--email')
1009
+ if (!email) {
1010
+ throw new Error('Usage: orizu teams members add --email <email> [--team <teamSlug>]')
1011
+ }
1012
+
1013
+ const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`, {
1014
+ method: 'POST',
1015
+ headers: { 'Content-Type': 'application/json' },
1016
+ body: JSON.stringify({ email }),
1017
+ })
1018
+ if (!response.ok) {
1019
+ throw new Error(`Failed to add team member: ${await response.text()}`)
1020
+ }
1021
+ const data = await parseJsonResponse<{ member: TeamMember }>(response, 'Team member add')
1022
+ console.log(`Added team member ${data.member.email} (${data.member.id})`)
1023
+ }
1024
+
1025
+ async function removeTeamMember() {
1026
+ const teamSlug = await resolveTeamSlug(getArg('--team'))
1027
+ const email = getArg('--email')
1028
+ if (!email) {
1029
+ throw new Error('Usage: orizu teams members remove --email <email> [--team <teamSlug>]')
1030
+ }
1031
+
1032
+ const members = await fetchTeamMembers(teamSlug)
1033
+ const member = members.find(item => item.email.toLowerCase() === email.toLowerCase())
1034
+ if (!member) {
1035
+ throw new Error(`No member found with email '${email}' in team '${teamSlug}'`)
1036
+ }
1037
+
1038
+ const response = await authedFetch(
1039
+ `/api/cli/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(member.id)}`,
1040
+ { method: 'DELETE' }
1041
+ )
1042
+ if (!response.ok) {
1043
+ throw new Error(`Failed to remove team member: ${await response.text()}`)
1044
+ }
1045
+ console.log(`Removed team member ${member.email}`)
1046
+ }
1047
+
1048
+ async function changeTeamMemberRole() {
1049
+ const teamSlug = getArg('--team')
1050
+ const email = getArg('--email')
1051
+ const role = getArg('--role')
1052
+ if (!teamSlug || !email || !role) {
1053
+ throw new Error('Usage: orizu teams members role --team <teamSlug> --email <email> --role <admin|member>')
1054
+ }
1055
+ if (!['admin', 'member'].includes(role)) {
1056
+ throw new Error('role must be one of: admin, member')
1057
+ }
1058
+
1059
+ const members = await fetchTeamMembers(teamSlug)
1060
+ const member = members.find(item => item.email.toLowerCase() === email.toLowerCase())
1061
+ if (!member) {
1062
+ throw new Error(`No member found with email '${email}' in team '${teamSlug}'`)
1063
+ }
1064
+
1065
+ const response = await authedFetch(
1066
+ `/api/cli/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(member.id)}`,
1067
+ {
1068
+ method: 'PATCH',
1069
+ headers: { 'Content-Type': 'application/json' },
1070
+ body: JSON.stringify({ role }),
1071
+ }
1072
+ )
1073
+
1074
+ if (!response.ok) {
1075
+ throw new Error(`Failed to update member role: ${await response.text()}`)
1076
+ }
1077
+
1078
+ console.log(`Updated ${member.email} role to ${role}`)
1079
+ }
1080
+
1081
+ async function uploadDataset() {
1082
+ const projectArg = getArg('--project')
1083
+ const fileArg = getArg('--file')
1084
+ const name = getArg('--name')
1085
+
1086
+ if (!fileArg) {
1087
+ throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
1088
+ }
1089
+
1090
+ const file = expandHomePath(fileArg)
1091
+ const project = await resolveProjectSlug(projectArg)
1092
+
1093
+ const { rows, sourceType } = parseDatasetFile(file)
1094
+ const datasetName = name || basename(file)
1095
+
1096
+ const response = await authedFetch('/api/cli/datasets/upload', {
1097
+ method: 'POST',
1098
+ headers: {
1099
+ 'Content-Type': 'application/json',
1100
+ },
1101
+ body: JSON.stringify({
1102
+ projectSlug: project,
1103
+ name: datasetName,
1104
+ rows,
1105
+ sourceType,
1106
+ }),
1107
+ })
1108
+
1109
+ if (!response.ok) {
1110
+ const body = await parseJsonResponse<{ error: string; code?: string }>(response, 'Dataset upload')
1111
+ throw new Error(`Upload failed: ${body.error}`)
1112
+ }
1113
+
1114
+ const data = await parseJsonResponse<{
1115
+ dataset: { id: string; name: string; rowCount: number; sourceType: string; url?: string }
1116
+ }>(response, 'Dataset upload')
1117
+
1118
+ console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`)
1119
+ if (data.dataset.url) {
1120
+ console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`)
1121
+ }
1122
+ }
1123
+
1124
+ async function downloadAnnotations() {
1125
+ let taskId = getArg('--task')
1126
+ const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
1127
+ const outPathArg = getArg('--out')
1128
+
1129
+ if (!['csv', 'json', 'jsonl'].includes(format)) {
1130
+ throw new Error('format must be one of: csv, json, jsonl')
1131
+ }
1132
+
1133
+ if (!taskId) {
1134
+ taskId = await selectTaskIdInteractively()
1135
+ }
1136
+
1137
+ const response = await authedFetch(`/api/cli/tasks/${taskId}/export?format=${format}`)
1138
+ if (!response.ok) {
1139
+ throw new Error(`Download failed: ${await response.text()}`)
1140
+ }
1141
+
1142
+ const fallbackName = `${taskId}.${format}`
1143
+ const filename = outPathArg
1144
+ ? expandHomePath(outPathArg)
1145
+ : fallbackName
1146
+
1147
+ const buffer = Buffer.from(await response.arrayBuffer())
1148
+ writeFileSync(filename, buffer)
1149
+
1150
+ console.log(`Saved ${format.toUpperCase()} export to ${filename}`)
1151
+ }
1152
+
1153
+ async function main() {
1154
+ const command = process.argv[2]
1155
+ const subcommand = process.argv[3]
1156
+
1157
+ if (!command) {
1158
+ printUsage()
1159
+ process.exit(1)
1160
+ }
1161
+
1162
+ if (command === 'login') {
1163
+ await login()
1164
+ return
1165
+ }
1166
+
1167
+ if (command === 'logout') {
1168
+ await logout()
1169
+ return
1170
+ }
1171
+
1172
+ if (command === 'whoami') {
1173
+ await whoami()
1174
+ return
1175
+ }
1176
+
1177
+ if (command === 'teams' && subcommand === 'list') {
1178
+ await listTeams()
1179
+ return
1180
+ }
1181
+
1182
+ if (command === 'teams' && subcommand === 'create') {
1183
+ await createTeam()
1184
+ return
1185
+ }
1186
+
1187
+ const teamsMembersAction = process.argv[4]
1188
+ if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
1189
+ await listTeamMembers()
1190
+ return
1191
+ }
1192
+ if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'add') {
1193
+ await addTeamMember()
1194
+ return
1195
+ }
1196
+ if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'remove') {
1197
+ await removeTeamMember()
1198
+ return
1199
+ }
1200
+ if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'role') {
1201
+ await changeTeamMemberRole()
1202
+ return
1203
+ }
1204
+
1205
+ if (command === 'projects' && subcommand === 'list') {
1206
+ await listProjects()
1207
+ return
1208
+ }
1209
+
1210
+ if (command === 'projects' && subcommand === 'create') {
1211
+ await createProject()
1212
+ return
1213
+ }
1214
+
1215
+ if (command === 'apps' && subcommand === 'list') {
1216
+ await listApps()
1217
+ return
1218
+ }
1219
+
1220
+ if (command === 'apps' && subcommand === 'create') {
1221
+ await createAppFromFile()
1222
+ return
1223
+ }
1224
+
1225
+ if (command === 'apps' && subcommand === 'update') {
1226
+ await updateAppFromFile()
1227
+ return
1228
+ }
1229
+
1230
+ if (command === 'apps' && subcommand === 'link-dataset') {
1231
+ await linkAppDataset()
1232
+ return
1233
+ }
1234
+
1235
+ if (command === 'tasks' && subcommand === 'list') {
1236
+ await listTasks()
1237
+ return
1238
+ }
1239
+
1240
+ if (command === 'tasks' && subcommand === 'create') {
1241
+ await createTask()
1242
+ return
1243
+ }
1244
+
1245
+ if (command === 'tasks' && subcommand === 'assign') {
1246
+ await assignTask()
1247
+ return
1248
+ }
1249
+
1250
+ if (command === 'tasks' && subcommand === 'status') {
1251
+ await taskStatus()
1252
+ return
1253
+ }
1254
+
1255
+ if (command === 'datasets' && subcommand === 'upload') {
1256
+ await uploadDataset()
1257
+ return
1258
+ }
1259
+
1260
+ if (command === 'tasks' && subcommand === 'export') {
1261
+ await downloadAnnotations()
1262
+ return
1263
+ }
1264
+
1265
+ printUsage()
1266
+ process.exit(1)
1267
+ }
1268
+
1269
+ main().catch(error => {
1270
+ console.error(error instanceof Error ? error.message : 'Unknown error')
1271
+ process.exit(1)
1272
+ })