orizu 0.1.5 → 0.2.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.
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from 'crypto'
3
3
  import { basename, extname } from 'path'
4
+ import { pathToFileURL } from 'url'
4
5
  import { createServer } from 'http'
5
6
  import { readFileSync, statSync, writeFileSync } from 'fs'
6
7
  import { spawn } from 'child_process'
@@ -11,7 +12,13 @@ import { parseDatasetFile } from './file-parser.js'
11
12
  import { streamJsonlRowChunks } from './jsonl-stream.js'
12
13
  import { parseDatasetReference } from './dataset-download.js'
13
14
  import { parseGlobalFlags } from './global-flags.js'
14
- import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js'
15
+ import {
16
+ assertSecureTokenTransport,
17
+ authedFetch,
18
+ getBaseUrl,
19
+ resolveLoginBaseUrl,
20
+ setGlobalFlags,
21
+ } from './http.js'
15
22
  import { formatTaskCreateError } from './task-create-error.js'
16
23
  import { LoginResponse } from './types.js'
17
24
 
@@ -67,6 +74,12 @@ interface DatasetSummary {
67
74
  teamSlug: string
68
75
  }
69
76
 
77
+ interface DatasetSelection {
78
+ datasetId: string
79
+ project?: string
80
+ name?: string
81
+ }
82
+
70
83
  type DatasetUploadSourceType = 'csv' | 'json' | 'jsonl'
71
84
 
72
85
  interface DatasetUploadResponse {
@@ -119,8 +132,28 @@ interface TaskStatusPayload {
119
132
  }
120
133
  }
121
134
 
135
+ function getCliVersion(): string {
136
+ const packageJson = JSON.parse(
137
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
138
+ ) as { version?: unknown }
139
+
140
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
141
+ throw new Error('Unable to read orizu CLI version.')
142
+ }
143
+
144
+ return packageJson.version
145
+ }
146
+
147
+ function printLine(message = '') {
148
+ output.write(`${message}\n`)
149
+ }
150
+
151
+ function printVersion() {
152
+ printLine(`orizu ${getCliVersion()}`)
153
+ }
154
+
122
155
  function printUsage() {
123
- console.log(`orizu global options:\n\n --local Use http://localhost:3000\n --server <url> Use a specific server origin (for example: https://preview.example.com)\n\norizu 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> --dataset <datasetId> --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 apps detail --app <appId> [--project <team/project>] [--json]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu tasks pause --task <taskId>\n orizu tasks unpause --task <taskId>\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>\n orizu datasets lock [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--reason <text>]\n orizu datasets clone [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
156
+ printLine(`orizu global options:\n\n --local Use http://localhost:3000\n --server <url> Use a specific server origin (for example: https://preview.example.com)\n --version, -v Print the orizu CLI version\n\norizu 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> --dataset <datasetId> --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 apps detail --app <appId> [--project <team/project>] [--json]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu tasks pause --task <taskId>\n orizu tasks unpause --task <taskId>\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>\n orizu datasets delete [--dataset <datasetId|datasetUrl>] [--project <team/project>]\n orizu datasets lock [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--reason <text>]\n orizu datasets clone [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
124
157
  }
125
158
 
126
159
  let cliArgs = process.argv.slice(2)
@@ -134,7 +167,7 @@ function getArg(name: string): string | null {
134
167
  return cliArgs[index + 1]
135
168
  }
136
169
 
137
- function normalizeSlugInput(slug: string): string {
170
+ export function normalizeSlugInput(slug: string): string {
138
171
  return slug.trim().toLowerCase()
139
172
  }
140
173
 
@@ -146,7 +179,7 @@ function hasArg(name: string): boolean {
146
179
  return cliArgs.includes(name)
147
180
  }
148
181
 
149
- function expandHomePath(path: string): string {
182
+ export function expandHomePath(path: string): string {
150
183
  if (path.startsWith('~/')) {
151
184
  const home = process.env.HOME || ''
152
185
  return `${home}/${path.slice(2)}`
@@ -159,14 +192,43 @@ function createCodeVerifier(): string {
159
192
  return randomBytes(32).toString('base64url')
160
193
  }
161
194
 
162
- function createCodeChallenge(verifier: string): string {
195
+ export function createCodeChallenge(verifier: string): string {
163
196
  return createHash('sha256').update(verifier).digest('base64url')
164
197
  }
165
198
 
199
+ export function sanitizeTerminalText(value: unknown): string {
200
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
201
+ }
202
+
203
+ export function validateBrowserUrl(url: string, expectedOrigin?: string): URL {
204
+ let parsed: URL
205
+ try {
206
+ parsed = new URL(url)
207
+ } catch {
208
+ throw new Error('Server returned an invalid browser URL.')
209
+ }
210
+
211
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
212
+ throw new Error('Server returned an unsupported browser URL scheme.')
213
+ }
214
+
215
+ if (parsed.username || parsed.password) {
216
+ throw new Error('Server returned a browser URL containing credentials.')
217
+ }
218
+
219
+ if (expectedOrigin && parsed.origin !== expectedOrigin) {
220
+ throw new Error('Server returned a browser URL for an unexpected origin.')
221
+ }
222
+
223
+ return parsed
224
+ }
225
+
166
226
  function openInBrowser(url: string) {
227
+ const parsed = validateBrowserUrl(url)
167
228
  const platform = process.platform
229
+ const href = parsed.href
168
230
  if (platform === 'darwin') {
169
- spawn('open', [url], {
231
+ spawn('open', [href], {
170
232
  detached: true,
171
233
  stdio: 'ignore',
172
234
  }).unref()
@@ -174,7 +236,7 @@ function openInBrowser(url: string) {
174
236
  }
175
237
 
176
238
  if (platform === 'win32') {
177
- spawn('cmd', ['/c', 'start', '', url], {
239
+ spawn('rundll32.exe', ['url.dll,FileProtocolHandler', href], {
178
240
  detached: true,
179
241
  stdio: 'ignore',
180
242
  windowsHide: true,
@@ -182,28 +244,38 @@ function openInBrowser(url: string) {
182
244
  return
183
245
  }
184
246
 
185
- spawn('xdg-open', [url], {
247
+ spawn('xdg-open', [href], {
186
248
  detached: true,
187
249
  stdio: 'ignore',
188
250
  }).unref()
189
251
  }
190
252
 
191
- function formatTerminalLink(url: string): string {
253
+ export function formatTerminalLink(url: string): string {
254
+ const safeUrl = sanitizeTerminalText(url)
255
+ try {
256
+ const parsed = validateBrowserUrl(safeUrl)
257
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
258
+ return safeUrl
259
+ }
260
+ } catch {
261
+ return safeUrl
262
+ }
263
+
192
264
  if (!isInteractiveTerminal()) {
193
- return url
265
+ return safeUrl
194
266
  }
195
267
 
196
- return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
268
+ return `\u001B]8;;${safeUrl}\u0007${safeUrl}\u001B]8;;\u0007`
197
269
  }
198
270
 
199
- async function parseJsonResponse<T>(response: Response, context: string): Promise<T> {
271
+ export async function parseJsonResponse<T>(response: Response, context: string): Promise<T> {
200
272
  const contentType = response.headers.get('content-type') || ''
201
273
  const rawBody = await response.text()
202
274
 
203
275
  if (!contentType.includes('application/json')) {
204
276
  throw new Error(
205
277
  `${context} returned non-JSON response (status ${response.status}). ` +
206
- `Body preview: ${rawBody.slice(0, 180)}`
278
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
207
279
  )
208
280
  }
209
281
 
@@ -212,7 +284,7 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
212
284
  } catch {
213
285
  throw new Error(
214
286
  `${context} returned invalid JSON (status ${response.status}). ` +
215
- `Body preview: ${rawBody.slice(0, 180)}`
287
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
216
288
  )
217
289
  }
218
290
  }
@@ -236,9 +308,9 @@ async function promptSelect<T>(
236
308
  return items[0]
237
309
  }
238
310
 
239
- console.log(`\n${title}`)
311
+ printLine(`\n${sanitizeTerminalText(title)}`)
240
312
  items.forEach((item, index) => {
241
- console.log(` ${index + 1}. ${label(item, index)}`)
313
+ printLine(` ${index + 1}. ${sanitizeTerminalText(label(item, index))}`)
242
314
  })
243
315
 
244
316
  const rl = createInterface({ input, output })
@@ -250,7 +322,7 @@ async function promptSelect<T>(
250
322
  return items[chosenIndex - 1]
251
323
  }
252
324
 
253
- console.log('Invalid selection. Enter a valid number from the list.')
325
+ printLine('Invalid selection. Enter a valid number from the list.')
254
326
  }
255
327
  } finally {
256
328
  rl.close()
@@ -354,7 +426,7 @@ async function resolveProjectSlug(projectArg: string | null): Promise<string> {
354
426
 
355
427
  const matchedTeam = teams.find(team => team.slug === teamSlug)
356
428
  if (!matchedTeam) {
357
- console.error(`Team '${teamSlug}' not found in your accessible teams.`)
429
+ console.error(`Team '${sanitizeTerminalText(teamSlug)}' not found in your accessible teams.`)
358
430
  const selectedTeam = await promptSelect(
359
431
  'Select a team',
360
432
  teams,
@@ -375,7 +447,7 @@ async function resolveProjectSlug(projectArg: string | null): Promise<string> {
375
447
  const matchedProject = projects.find(project => project.slug === projectSlug)
376
448
 
377
449
  if (!matchedProject) {
378
- console.error(`Project '${projectSlug}' not found in team '${matchedTeam.slug}'.`)
450
+ console.error(`Project '${sanitizeTerminalText(projectSlug)}' not found in team '${sanitizeTerminalText(matchedTeam.slug)}'.`)
379
451
  const selectedProject = await promptSelect(
380
452
  `Select a project in ${matchedTeam.slug}`,
381
453
  projects,
@@ -434,7 +506,7 @@ async function selectAppIdInteractively(projectArg: string | null): Promise<{ ap
434
506
  }
435
507
  }
436
508
 
437
- async function selectDatasetInteractively(projectArg: string | null): Promise<{ datasetId: string; project: string }> {
509
+ async function selectDatasetInteractively(projectArg: string | null): Promise<DatasetSelection> {
438
510
  let project = projectArg
439
511
  if (!project) {
440
512
  project = await resolveProjectSlug(null)
@@ -451,62 +523,63 @@ async function selectDatasetInteractively(projectArg: string | null): Promise<{
451
523
  return {
452
524
  datasetId: dataset.id,
453
525
  project,
526
+ name: dataset.name,
454
527
  }
455
528
  }
456
529
 
457
530
  function printTeams(teams: Team[]) {
458
531
  if (teams.length === 0) {
459
- console.log('No teams found.')
532
+ printLine('No teams found.')
460
533
  return
461
534
  }
462
535
 
463
536
  const rows = teams.map(team => ({
464
- slug: team.slug,
465
- name: team.name || '-',
466
- role: team.role || '-',
537
+ slug: sanitizeTerminalText(team.slug),
538
+ name: sanitizeTerminalText(team.name || '-'),
539
+ role: sanitizeTerminalText(team.role || '-'),
467
540
  }))
468
541
 
469
542
  const slugWidth = Math.max('TEAM SLUG'.length, ...rows.map(row => row.slug.length))
470
543
  const nameWidth = Math.max('TEAM NAME'.length, ...rows.map(row => row.name.length))
471
544
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
472
545
 
473
- console.log(
546
+ printLine(
474
547
  `${'TEAM SLUG'.padEnd(slugWidth)} ${'TEAM NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
475
548
  )
476
- console.log(
549
+ printLine(
477
550
  `${'-'.repeat(slugWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
478
551
  )
479
552
 
480
553
  rows.forEach(row => {
481
- console.log(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`)
554
+ printLine(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`)
482
555
  })
483
556
  }
484
557
 
485
558
  function printProjects(projects: Project[]) {
486
559
  if (projects.length === 0) {
487
- console.log('No projects found.')
560
+ printLine('No projects found.')
488
561
  return
489
562
  }
490
563
 
491
564
  const rows = projects.map(project => ({
492
- project: `${project.teamSlug}/${project.slug}`,
493
- name: project.name || '-',
494
- role: project.role || '-',
565
+ project: sanitizeTerminalText(`${project.teamSlug}/${project.slug}`),
566
+ name: sanitizeTerminalText(project.name || '-'),
567
+ role: sanitizeTerminalText(project.role || '-'),
495
568
  }))
496
569
 
497
570
  const projectWidth = Math.max('TEAM/PROJECT'.length, ...rows.map(row => row.project.length))
498
571
  const nameWidth = Math.max('PROJECT NAME'.length, ...rows.map(row => row.name.length))
499
572
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
500
573
 
501
- console.log(
574
+ printLine(
502
575
  `${'TEAM/PROJECT'.padEnd(projectWidth)} ${'PROJECT NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
503
576
  )
504
- console.log(
577
+ printLine(
505
578
  `${'-'.repeat(projectWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
506
579
  )
507
580
 
508
581
  rows.forEach(row => {
509
- console.log(
582
+ printLine(
510
583
  `${row.project.padEnd(projectWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`
511
584
  )
512
585
  })
@@ -514,16 +587,16 @@ function printProjects(projects: Project[]) {
514
587
 
515
588
  function printTasks(tasks: Task[]) {
516
589
  if (tasks.length === 0) {
517
- console.log('No tasks found.')
590
+ printLine('No tasks found.')
518
591
  return
519
592
  }
520
593
 
521
594
  const rows = tasks.map(task => ({
522
- id: task.id,
523
- name: task.title || '-',
524
- status: task.status || '-',
595
+ id: sanitizeTerminalText(task.id),
596
+ name: sanitizeTerminalText(task.title || '-'),
597
+ status: sanitizeTerminalText(task.status || '-'),
525
598
  project: task.teamSlug && task.projectSlug
526
- ? `${task.teamSlug}/${task.projectSlug}`
599
+ ? sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)
527
600
  : 'unknown-project',
528
601
  }))
529
602
 
@@ -531,15 +604,15 @@ function printTasks(tasks: Task[]) {
531
604
  const nameWidth = Math.max('TASK NAME'.length, ...rows.map(row => row.name.length))
532
605
  const statusWidth = Math.max('STATUS'.length, ...rows.map(row => row.status.length))
533
606
 
534
- console.log(
607
+ printLine(
535
608
  `${'TASK ID'.padEnd(idWidth)} ${'TASK NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} TEAM/PROJECT`
536
609
  )
537
- console.log(
610
+ printLine(
538
611
  `${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(statusWidth)} ------------`
539
612
  )
540
613
 
541
614
  rows.forEach(row => {
542
- console.log(
615
+ printLine(
543
616
  `${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.project}`
544
617
  )
545
618
  })
@@ -547,13 +620,13 @@ function printTasks(tasks: Task[]) {
547
620
 
548
621
  function printApps(apps: AppSummary[]) {
549
622
  if (apps.length === 0) {
550
- console.log('No apps found.')
623
+ printLine('No apps found.')
551
624
  return
552
625
  }
553
626
 
554
627
  const rows = apps.map(app => ({
555
- id: app.id,
556
- name: app.name || '-',
628
+ id: sanitizeTerminalText(app.id),
629
+ name: sanitizeTerminalText(app.name || '-'),
557
630
  version: `v${app.currentVersionNum || 1}`,
558
631
  }))
559
632
 
@@ -561,25 +634,25 @@ function printApps(apps: AppSummary[]) {
561
634
  const nameWidth = Math.max('APP NAME'.length, ...rows.map(row => row.name.length))
562
635
  const versionWidth = Math.max('VERSION'.length, ...rows.map(row => row.version.length))
563
636
 
564
- console.log(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`)
565
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`)
637
+ printLine(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`)
638
+ printLine(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`)
566
639
 
567
640
  rows.forEach(row => {
568
- console.log(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`)
641
+ printLine(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`)
569
642
  })
570
643
  }
571
644
 
572
645
  function printTeamMembers(members: TeamMember[]) {
573
646
  if (members.length === 0) {
574
- console.log('No team members found.')
647
+ printLine('No team members found.')
575
648
  return
576
649
  }
577
650
 
578
651
  const rows = members.map(member => ({
579
- id: member.id,
580
- userId: member.user_id || '-',
581
- email: member.email || '-',
582
- role: member.role || '-',
652
+ id: sanitizeTerminalText(member.id),
653
+ userId: sanitizeTerminalText(member.user_id || '-'),
654
+ email: sanitizeTerminalText(member.email || '-'),
655
+ role: sanitizeTerminalText(member.role || '-'),
583
656
  }))
584
657
 
585
658
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
@@ -587,14 +660,14 @@ function printTeamMembers(members: TeamMember[]) {
587
660
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
588
661
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
589
662
 
590
- console.log(
663
+ printLine(
591
664
  `${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`
592
665
  )
593
- console.log(
666
+ printLine(
594
667
  `${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`
595
668
  )
596
669
  rows.forEach(row => {
597
- console.log(
670
+ printLine(
598
671
  `${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`
599
672
  )
600
673
  })
@@ -602,18 +675,18 @@ function printTeamMembers(members: TeamMember[]) {
602
675
 
603
676
  function printTaskStatusSummary(data: TaskStatusPayload) {
604
677
  const task = data.task
605
- console.log(`Task: ${task.title} (${task.id})`)
606
- console.log(`Status: ${task.status}`)
607
- console.log(`Project: ${task.teamSlug}/${task.projectSlug}`)
608
- console.log(`Progress: ${task.progressPercentage}%`)
609
- console.log(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`)
610
- console.log(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`)
678
+ printLine(`Task: ${sanitizeTerminalText(task.title)} (${sanitizeTerminalText(task.id)})`)
679
+ printLine(`Status: ${sanitizeTerminalText(task.status)}`)
680
+ printLine(`Project: ${sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)}`)
681
+ printLine(`Progress: ${task.progressPercentage}%`)
682
+ printLine(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`)
683
+ printLine(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`)
611
684
 
612
685
  if (task.assignees.length > 0) {
613
- console.log('\nAssignees')
686
+ printLine('\nAssignees')
614
687
  task.assignees.forEach(assignee => {
615
- console.log(
616
- ` ${assignee.email}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`
688
+ printLine(
689
+ ` ${sanitizeTerminalText(assignee.email)}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`
617
690
  )
618
691
  })
619
692
  }
@@ -748,6 +821,7 @@ function renderCliAuthBrowserPage(status: 'success' | 'error'): string {
748
821
 
749
822
  async function login() {
750
823
  const baseUrl = resolveLoginBaseUrl()
824
+ assertSecureTokenTransport(baseUrl)
751
825
  const codeVerifier = createCodeVerifier()
752
826
  const codeChallenge = createCodeChallenge(codeVerifier)
753
827
  const callbackPort = resolveAuthCallbackPort()
@@ -807,8 +881,9 @@ async function login() {
807
881
  response,
808
882
  'CLI auth start'
809
883
  )
810
- console.log(`Opening browser for login: ${authorizeUrl}`)
811
- openInBrowser(authorizeUrl)
884
+ const safeAuthorizeUrl = validateBrowserUrl(authorizeUrl, baseUrl).href
885
+ printLine(`Opening browser for login: ${sanitizeTerminalText(safeAuthorizeUrl)}`)
886
+ openInBrowser(safeAuthorizeUrl)
812
887
  } catch (error) {
813
888
  server.close()
814
889
  reject(error)
@@ -828,13 +903,16 @@ async function login() {
828
903
  }
829
904
 
830
905
  const loginData = await parseJsonResponse<LoginResponse>(exchangeResponse, 'CLI auth exchange')
906
+ if (!loginData.apiKey) {
907
+ throw new Error('Server did not return an API key. Upgrade the Orizu server and run `orizu login` again.')
908
+ }
909
+
831
910
  saveServerCredentials(baseUrl, {
832
- accessToken: loginData.accessToken,
833
- refreshToken: loginData.refreshToken,
834
- expiresAt: loginData.expiresAt,
911
+ credentialType: 'pat',
912
+ apiKey: loginData.apiKey,
835
913
  })
836
914
 
837
- console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`)
915
+ printLine(`Logged in as ${sanitizeTerminalText(loginData.user.email ?? loginData.user.id)}`)
838
916
  }
839
917
 
840
918
  async function whoami() {
@@ -844,28 +922,44 @@ async function whoami() {
844
922
  }
845
923
 
846
924
  const data = await response.json() as { user: { id: string; email: string | null } }
847
- console.log(data.user.email ?? data.user.id)
925
+ printLine(sanitizeTerminalText(data.user.email ?? data.user.id))
848
926
  }
849
927
 
850
928
  async function logout() {
851
929
  const baseUrl = getBaseUrl()
852
930
  const credentials = getServerCredentials(baseUrl)
853
931
  if (!credentials) {
854
- console.log(`Already logged out for ${baseUrl}.`)
932
+ printLine(`Already logged out for ${sanitizeTerminalText(baseUrl)}.`)
855
933
  return
856
934
  }
857
935
 
858
- await fetch(`${baseUrl}/api/cli/auth/logout`, {
859
- method: 'POST',
860
- headers: {
861
- 'Content-Type': 'application/json',
862
- Authorization: `Bearer ${credentials.accessToken}`,
863
- },
864
- body: JSON.stringify({ refreshToken: credentials.refreshToken }),
865
- }).catch(() => undefined)
936
+ let remoteLogoutError: string | null = null
937
+ try {
938
+ assertSecureTokenTransport(baseUrl)
939
+ const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
940
+ method: 'POST',
941
+ headers: {
942
+ 'Content-Type': 'application/json',
943
+ Authorization: `Bearer ${'accessToken' in credentials ? credentials.accessToken : credentials.apiKey}`,
944
+ },
945
+ body: 'refreshToken' in credentials
946
+ ? JSON.stringify({ refreshToken: credentials.refreshToken })
947
+ : undefined,
948
+ })
949
+
950
+ if (!response.ok) {
951
+ remoteLogoutError = sanitizeTerminalText(await response.text()).slice(0, 180)
952
+ }
953
+ } catch (error) {
954
+ const message = error instanceof Error ? error.message : String(error)
955
+ remoteLogoutError = sanitizeTerminalText(message).slice(0, 180)
956
+ }
866
957
 
867
958
  clearServerCredentials(baseUrl)
868
- console.log(`Logged out from ${baseUrl}.`)
959
+ if (remoteLogoutError) {
960
+ console.warn(`Warning: remote logout failed: ${remoteLogoutError}`)
961
+ }
962
+ printLine(`Logged out from ${sanitizeTerminalText(baseUrl)}.`)
869
963
  }
870
964
 
871
965
  async function listTeams() {
@@ -914,7 +1008,7 @@ async function createTeam() {
914
1008
  }
915
1009
 
916
1010
  const data = await parseJsonResponse<{ team: Team }>(response, 'Team create')
917
- console.log(`Created team: ${data.team.name} (${data.team.slug})`)
1011
+ printLine(`Created team: ${sanitizeTerminalText(data.team.name)} (${sanitizeTerminalText(data.team.slug)})`)
918
1012
  }
919
1013
 
920
1014
  async function listProjects() {
@@ -955,7 +1049,7 @@ async function createProject() {
955
1049
  const data = await parseJsonResponse<{
956
1050
  project: { id: string; name: string; slug: string; teamSlug: string }
957
1051
  }>(response, 'Project create')
958
- console.log(`Created project ${data.project.teamSlug}/${data.project.slug}`)
1052
+ printLine(`Created project ${sanitizeTerminalText(`${data.project.teamSlug}/${data.project.slug}`)}`)
959
1053
  }
960
1054
 
961
1055
  async function listTasks() {
@@ -1032,9 +1126,9 @@ async function createAppFromFile() {
1032
1126
  app: { id: string; name: string; versionNum: number; componentName?: string }
1033
1127
  warnings?: string[]
1034
1128
  }>(response, 'App create')
1035
- console.log(`Created app ${data.app.name} (${data.app.id}) v${data.app.versionNum}`)
1129
+ printLine(`Created app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) v${data.app.versionNum}`)
1036
1130
  if (data.warnings?.length) {
1037
- console.log(`Warnings: ${data.warnings.join('; ')}`)
1131
+ printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
1038
1132
  }
1039
1133
  }
1040
1134
 
@@ -1077,9 +1171,9 @@ async function updateAppFromFile() {
1077
1171
  app: { id: string; name: string; versionNum: number; componentName?: string }
1078
1172
  warnings?: string[]
1079
1173
  }>(response, 'App update')
1080
- console.log(`Updated app ${data.app.name} (${data.app.id}) to v${data.app.versionNum}`)
1174
+ printLine(`Updated app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) to v${data.app.versionNum}`)
1081
1175
  if (data.warnings?.length) {
1082
- console.log(`Warnings: ${data.warnings.join('; ')}`)
1176
+ printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`)
1083
1177
  }
1084
1178
  }
1085
1179
 
@@ -1124,8 +1218,8 @@ async function linkAppDataset() {
1124
1218
  versionNum: number
1125
1219
  }>(response, 'App link dataset')
1126
1220
 
1127
- console.log(
1128
- `Linked dataset ${data.linkedDataset.name} (${data.linkedDataset.id}) to app ${data.app.name} (${data.app.id}) version ${data.versionNum}`
1221
+ printLine(
1222
+ `Linked dataset ${sanitizeTerminalText(data.linkedDataset.name)} (${sanitizeTerminalText(data.linkedDataset.id)}) to app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) version ${data.versionNum}`
1129
1223
  )
1130
1224
  }
1131
1225
 
@@ -1180,7 +1274,7 @@ async function createTask() {
1180
1274
  const cliError = await formatTaskCreateError(response)
1181
1275
  if (hasArg('--json')) {
1182
1276
  const payload = cliError.structuredPayload ?? { error: cliError.message }
1183
- console.log(JSON.stringify({
1277
+ printLine(JSON.stringify({
1184
1278
  ...payload,
1185
1279
  httpStatus: cliError.httpStatus,
1186
1280
  }, null, 2))
@@ -1203,7 +1297,7 @@ async function createTask() {
1203
1297
  }>(response, 'Task create')
1204
1298
 
1205
1299
  if (hasArg('--json')) {
1206
- console.log(JSON.stringify({
1300
+ printLine(JSON.stringify({
1207
1301
  taskId: data.task.id,
1208
1302
  datasetId,
1209
1303
  versionId: data.task.versionId,
@@ -1221,15 +1315,15 @@ async function createTask() {
1221
1315
 
1222
1316
  const baseUrl = getBaseUrl()
1223
1317
  const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`
1224
- console.log(
1225
- `Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
1226
- `\n Task ID: ${data.task.id}` +
1227
- `\n Dataset ID: ${datasetId}` +
1228
- `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
1318
+ printLine(
1319
+ `Created task ${sanitizeTerminalText(data.task.title)} (${sanitizeTerminalText(data.task.id)}) [${sanitizeTerminalText(data.task.status)}]` +
1320
+ `\n Task ID: ${sanitizeTerminalText(data.task.id)}` +
1321
+ `\n Dataset ID: ${sanitizeTerminalText(datasetId)}` +
1322
+ `\n Version: v${data.task.versionNum} (${sanitizeTerminalText(data.task.versionId)})` +
1229
1323
  `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
1230
1324
  `\n Assignments: ${data.assignmentsCreated}` +
1231
- (data.warning ? `\n Warning: ${data.warning}` : '') +
1232
- `\n URL: ${taskUrl}`
1325
+ (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '') +
1326
+ `\n URL: ${sanitizeTerminalText(taskUrl)}`
1233
1327
  )
1234
1328
  }
1235
1329
 
@@ -1252,7 +1346,7 @@ async function assignTask() {
1252
1346
  }
1253
1347
 
1254
1348
  const data = await parseJsonResponse<{ assignmentsCreated: number }>(response, 'Task assign')
1255
- console.log(`Created ${data.assignmentsCreated} assignments.`)
1349
+ printLine(`Created ${data.assignmentsCreated} assignments.`)
1256
1350
  }
1257
1351
 
1258
1352
  async function taskStatus() {
@@ -1274,7 +1368,7 @@ async function taskStatus() {
1274
1368
  } catch {
1275
1369
  // keep raw body as error
1276
1370
  }
1277
- console.log(JSON.stringify({
1371
+ printLine(JSON.stringify({
1278
1372
  ...errorPayload,
1279
1373
  httpStatus: response.status,
1280
1374
  }, null, 2))
@@ -1292,7 +1386,7 @@ async function taskStatus() {
1292
1386
 
1293
1387
  const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
1294
1388
  if (hasArg('--json')) {
1295
- console.log(JSON.stringify(data, null, 2))
1389
+ printLine(JSON.stringify(data, null, 2))
1296
1390
  return
1297
1391
  }
1298
1392
 
@@ -1319,7 +1413,7 @@ async function updateTaskStatus(targetStatus: 'paused' | 'active') {
1319
1413
 
1320
1414
  const data = await parseJsonResponse<{ task: { id: string; status: string } }>(response, 'Task status update')
1321
1415
  const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused'
1322
- console.log(`${action} task ${data.task.id} [${data.task.status}]`)
1416
+ printLine(`${action} task ${sanitizeTerminalText(data.task.id)} [${sanitizeTerminalText(data.task.status)}]`)
1323
1417
  }
1324
1418
 
1325
1419
  interface AppDetailPayload {
@@ -1369,25 +1463,25 @@ async function appDetail() {
1369
1463
  }
1370
1464
 
1371
1465
  if (hasArg('--json')) {
1372
- console.log(JSON.stringify(detail, null, 2))
1466
+ printLine(JSON.stringify(detail, null, 2))
1373
1467
  return
1374
1468
  }
1375
1469
 
1376
- console.log(`App: ${detail.name} (${detail.id})`)
1377
- console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`)
1470
+ printLine(`App: ${sanitizeTerminalText(detail.name)} (${sanitizeTerminalText(detail.id)})`)
1471
+ printLine(` Project: ${sanitizeTerminalText(`${detail.teamSlug}/${detail.projectSlug}`)}`)
1378
1472
  if (detail.currentVersion) {
1379
- console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`)
1380
- console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`)
1381
- console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`)
1473
+ printLine(` Current version: v${detail.currentVersion.versionNum} (${sanitizeTerminalText(detail.currentVersion.versionId)})`)
1474
+ printLine(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`)
1475
+ printLine(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`)
1382
1476
  } else {
1383
- console.log(` Current version: none`)
1477
+ printLine(` Current version: none`)
1384
1478
  }
1385
- console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`)
1479
+ printLine(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`)
1386
1480
  if (detail.createdByEmail) {
1387
- console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`)
1481
+ printLine(` Created by: ${sanitizeTerminalText(detail.createdByName || detail.createdByEmail)}`)
1388
1482
  }
1389
- console.log(` Created: ${detail.createdAt}`)
1390
- console.log(` Updated: ${detail.updatedAt}`)
1483
+ printLine(` Created: ${sanitizeTerminalText(detail.createdAt)}`)
1484
+ printLine(` Updated: ${sanitizeTerminalText(detail.updatedAt)}`)
1391
1485
  }
1392
1486
 
1393
1487
  async function listTeamMembers() {
@@ -1412,7 +1506,7 @@ async function addTeamMember() {
1412
1506
  throw new Error(`Failed to add team member: ${await response.text()}`)
1413
1507
  }
1414
1508
  const data = await parseJsonResponse<{ member: TeamMember }>(response, 'Team member add')
1415
- console.log(`Added team member ${data.member.email} (${data.member.id})`)
1509
+ printLine(`Added team member ${sanitizeTerminalText(data.member.email)} (${sanitizeTerminalText(data.member.id)})`)
1416
1510
  }
1417
1511
 
1418
1512
  async function removeTeamMember() {
@@ -1435,7 +1529,7 @@ async function removeTeamMember() {
1435
1529
  if (!response.ok) {
1436
1530
  throw new Error(`Failed to remove team member: ${await response.text()}`)
1437
1531
  }
1438
- console.log(`Removed team member ${member.email}`)
1532
+ printLine(`Removed team member ${sanitizeTerminalText(member.email)}`)
1439
1533
  }
1440
1534
 
1441
1535
  async function changeTeamMemberRole() {
@@ -1469,7 +1563,7 @@ async function changeTeamMemberRole() {
1469
1563
  throw new Error(`Failed to update member role: ${await response.text()}`)
1470
1564
  }
1471
1565
 
1472
- console.log(`Updated ${member.email} role to ${role}`)
1566
+ printLine(`Updated ${sanitizeTerminalText(member.email)} role to ${sanitizeTerminalText(role)}`)
1473
1567
  }
1474
1568
 
1475
1569
  async function createDatasetFromRows(
@@ -1521,7 +1615,7 @@ async function uploadJsonlDatasetInChunks(
1521
1615
 
1522
1616
  throw new Error(
1523
1617
  `Upload stopped while reading the next JSONL chunk: ${message}\n` +
1524
- `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
1618
+ `Dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) was created and ${totalUploaded} rows were uploaded. ` +
1525
1619
  `Fix the file, remove the first ${totalUploaded} rows, and run ` +
1526
1620
  `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`
1527
1621
  )
@@ -1533,7 +1627,7 @@ async function uploadJsonlDatasetInChunks(
1533
1627
 
1534
1628
  const chunk = nextChunk.value
1535
1629
  chunkIndex += 1
1536
- console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1630
+ printLine(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1537
1631
 
1538
1632
  try {
1539
1633
  if (!dataset) {
@@ -1568,9 +1662,9 @@ async function uploadJsonlDatasetInChunks(
1568
1662
  throw new Error('Dataset file contains no rows')
1569
1663
  }
1570
1664
 
1571
- console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`)
1665
+ printLine(`Uploaded dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) with ${dataset.rowCount} rows.`)
1572
1666
  if (dataset.url) {
1573
- console.log(`View dataset: ${formatTerminalLink(dataset.url)}`)
1667
+ printLine(`View dataset: ${formatTerminalLink(dataset.url)}`)
1574
1668
  }
1575
1669
  }
1576
1670
 
@@ -1595,9 +1689,9 @@ async function uploadDataset() {
1595
1689
  const { rows, sourceType } = parseDatasetFile(file)
1596
1690
  const data = await createDatasetFromRows(project, datasetName, sourceType, rows)
1597
1691
 
1598
- console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`)
1692
+ printLine(`Uploaded dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) with ${data.dataset.rowCount} rows.`)
1599
1693
  if (data.dataset.url) {
1600
- console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`)
1694
+ printLine(`View dataset: ${formatTerminalLink(data.dataset.url)}`)
1601
1695
  }
1602
1696
  }
1603
1697
 
@@ -1647,7 +1741,7 @@ async function downloadDataset() {
1647
1741
  const bytes = new Uint8Array(await response.arrayBuffer())
1648
1742
  writeFileSync(filename, bytes)
1649
1743
 
1650
- console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`)
1744
+ printLine(`Saved dataset ${sanitizeTerminalText(datasetId)} (${format.toUpperCase()}) to ${sanitizeTerminalText(filename)}`)
1651
1745
  }
1652
1746
 
1653
1747
  const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
@@ -1698,7 +1792,7 @@ async function appendJsonlDatasetRowsInChunks(datasetId: string, file: string) {
1698
1792
 
1699
1793
  const chunk = nextChunk.value
1700
1794
  chunkIndex += 1
1701
- console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1795
+ printLine(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1702
1796
 
1703
1797
  try {
1704
1798
  const data = await appendChunk(datasetId, chunk)
@@ -1718,8 +1812,8 @@ async function appendJsonlDatasetRowsInChunks(datasetId: string, file: string) {
1718
1812
  throw new Error('Dataset append file must contain at least one row')
1719
1813
  }
1720
1814
 
1721
- console.log(
1722
- `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1815
+ printLine(
1816
+ `Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`
1723
1817
  )
1724
1818
  }
1725
1819
 
@@ -1781,8 +1875,8 @@ async function appendDatasetRows() {
1781
1875
 
1782
1876
  if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1783
1877
  const data = await appendChunk(datasetId, rows)
1784
- console.log(
1785
- `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1878
+ printLine(
1879
+ `Appended ${data.appendedCount} rows to dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`
1786
1880
  )
1787
1881
  return
1788
1882
  }
@@ -1796,7 +1890,7 @@ async function appendDatasetRows() {
1796
1890
  const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS)
1797
1891
  const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1
1798
1892
 
1799
- console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`)
1893
+ printLine(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`)
1800
1894
  try {
1801
1895
  const data = await appendChunk(datasetId, chunk)
1802
1896
  totalAppended += data.appendedCount
@@ -1812,8 +1906,8 @@ async function appendDatasetRows() {
1812
1906
  }
1813
1907
 
1814
1908
  if (lastResult) {
1815
- console.log(
1816
- `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1909
+ printLine(
1910
+ `Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`
1817
1911
  )
1818
1912
  }
1819
1913
  }
@@ -1873,8 +1967,8 @@ async function editDatasetRows() {
1873
1967
  updatedCount: number
1874
1968
  }>(response, 'Dataset edit rows')
1875
1969
 
1876
- console.log(
1877
- `Updated ${data.updatedCount} rows in dataset ${data.dataset.name} (${data.dataset.id}). Current row count: ${data.dataset.rowCount}`
1970
+ printLine(
1971
+ `Updated ${data.updatedCount} rows in dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Current row count: ${data.dataset.rowCount}`
1878
1972
  )
1879
1973
  }
1880
1974
 
@@ -1912,11 +2006,63 @@ async function deleteDatasetRows() {
1912
2006
  deletedCount: number
1913
2007
  }>(response, 'Dataset delete rows')
1914
2008
 
1915
- console.log(
1916
- `Deleted ${data.deletedCount} rows from dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
2009
+ printLine(
2010
+ `Deleted ${data.deletedCount} rows from dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`
1917
2011
  )
1918
2012
  }
1919
2013
 
2014
+ async function confirmDatasetDeletion(dataset: DatasetSelection) {
2015
+ if (!isInteractiveTerminal()) {
2016
+ throw new Error(
2017
+ 'Dataset deletion requires an interactive terminal confirmation. There is no non-interactive delete option.'
2018
+ )
2019
+ }
2020
+
2021
+ const safeName = dataset.name ? ` (${sanitizeTerminalText(dataset.name)})` : ''
2022
+ printLine(
2023
+ `This will permanently delete dataset ${sanitizeTerminalText(dataset.datasetId)}${safeName}.`
2024
+ )
2025
+ printLine('Type the dataset id exactly to confirm.')
2026
+
2027
+ const rl = createInterface({ input, output })
2028
+ try {
2029
+ const answer = (await rl.question('Dataset id: ')).trim()
2030
+ if (answer !== dataset.datasetId) {
2031
+ throw new Error('Dataset deletion cancelled.')
2032
+ }
2033
+ } finally {
2034
+ rl.close()
2035
+ }
2036
+ }
2037
+
2038
+ async function deleteDataset() {
2039
+ const projectArg = getArg('--project')
2040
+ const datasetInput = getDatasetReferenceInput()
2041
+
2042
+ let dataset: DatasetSelection
2043
+ if (datasetInput) {
2044
+ dataset = { datasetId: parseDatasetReference(datasetInput).datasetId }
2045
+ } else {
2046
+ dataset = await selectDatasetInteractively(projectArg)
2047
+ }
2048
+
2049
+ await confirmDatasetDeletion(dataset)
2050
+
2051
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(dataset.datasetId)}`, {
2052
+ method: 'DELETE',
2053
+ })
2054
+
2055
+ if (!response.ok) {
2056
+ throw new Error(`Delete failed: ${await response.text()}`)
2057
+ }
2058
+
2059
+ const data = await parseJsonResponse<{
2060
+ dataset: { id: string }
2061
+ }>(response, 'Dataset delete')
2062
+
2063
+ printLine(`Deleted dataset ${sanitizeTerminalText(data.dataset.id)}.`)
2064
+ }
2065
+
1920
2066
  async function lockDataset() {
1921
2067
  const projectArg = getArg('--project')
1922
2068
  const datasetInput = getDatasetReferenceInput()
@@ -1950,8 +2096,8 @@ async function lockDataset() {
1950
2096
  }
1951
2097
  }>(response, 'Dataset lock')
1952
2098
 
1953
- console.log(
1954
- `Locked dataset ${data.dataset.name} (${data.dataset.id}) at ${data.dataset.lockedAt}. Row count: ${data.dataset.rowCount}`
2099
+ printLine(
2100
+ `Locked dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) at ${sanitizeTerminalText(data.dataset.lockedAt)}. Row count: ${data.dataset.rowCount}`
1955
2101
  )
1956
2102
  }
1957
2103
 
@@ -1987,8 +2133,8 @@ async function cloneDataset() {
1987
2133
  }
1988
2134
  }>(response, 'Dataset clone')
1989
2135
 
1990
- console.log(
1991
- `Cloned dataset ${data.dataset.parentDatasetId} -> ${data.dataset.name} (${data.dataset.id}). Row count: ${data.dataset.rowCount}`
2136
+ printLine(
2137
+ `Cloned dataset ${sanitizeTerminalText(data.dataset.parentDatasetId)} -> ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Row count: ${data.dataset.rowCount}`
1992
2138
  )
1993
2139
  }
1994
2140
 
@@ -2018,17 +2164,22 @@ async function downloadAnnotations() {
2018
2164
  const bytes = new Uint8Array(await response.arrayBuffer())
2019
2165
  writeFileSync(filename, bytes)
2020
2166
 
2021
- console.log(`Saved ${format.toUpperCase()} export to ${filename}`)
2167
+ printLine(`Saved ${format.toUpperCase()} export to ${sanitizeTerminalText(filename)}`)
2022
2168
  }
2023
2169
 
2024
- async function main() {
2025
- const parsed = parseGlobalFlags(process.argv.slice(2))
2170
+ export async function main(rawArgs = process.argv.slice(2)) {
2171
+ const parsed = parseGlobalFlags(rawArgs)
2026
2172
  setGlobalFlags(parsed.flags)
2027
2173
  cliArgs = parsed.args
2028
2174
 
2029
2175
  const command = cliArgs[0]
2030
2176
  const subcommand = cliArgs[1]
2031
2177
 
2178
+ if (command === '--version' || command === '-v') {
2179
+ printVersion()
2180
+ return
2181
+ }
2182
+
2032
2183
  if (!command) {
2033
2184
  printUsage()
2034
2185
  process.exit(1)
@@ -2167,6 +2318,11 @@ async function main() {
2167
2318
  return
2168
2319
  }
2169
2320
 
2321
+ if (command === 'datasets' && subcommand === 'delete') {
2322
+ await deleteDataset()
2323
+ return
2324
+ }
2325
+
2170
2326
  if (command === 'datasets' && subcommand === 'lock') {
2171
2327
  await lockDataset()
2172
2328
  return
@@ -2186,7 +2342,9 @@ async function main() {
2186
2342
  process.exit(1)
2187
2343
  }
2188
2344
 
2189
- main().catch(error => {
2190
- console.error(error instanceof Error ? error.message : 'Unknown error')
2191
- process.exit(1)
2192
- })
2345
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2346
+ main().catch(error => {
2347
+ console.error(sanitizeTerminalText(error instanceof Error ? error.message : 'Unknown error'))
2348
+ process.exit(1)
2349
+ })
2350
+ }