orizu 0.0.8 → 0.1.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
@@ -2,15 +2,17 @@
2
2
  import { createHash, randomBytes } from 'crypto'
3
3
  import { basename } from 'path'
4
4
  import { createServer } from 'http'
5
- import { readFileSync, writeFileSync } from 'fs'
5
+ import { readFileSync, statSync, writeFileSync } from 'fs'
6
6
  import { spawn } from 'child_process'
7
7
  import { createInterface } from 'readline/promises'
8
8
  import { stdin as input, stdout as output } from 'process'
9
9
  import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js'
10
10
  import { parseDatasetFile } from './file-parser.js'
11
11
  import { parseDatasetReference } from './dataset-download.js'
12
+ import { extractErrorMessage } from './error-response.js'
12
13
  import { parseGlobalFlags } from './global-flags.js'
13
14
  import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js'
15
+ import { formatTaskCreateError } from './task-create-error.js'
14
16
  import { LoginResponse } from './types.js'
15
17
 
16
18
  interface Team {
@@ -106,7 +108,7 @@ interface TaskStatusPayload {
106
108
  }
107
109
 
108
110
  function printUsage() {
109
- 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 tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userId1,userId2> [--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 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 delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--row-ids <id1,id2>] [--row-indices <n1,n2>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
111
+ 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>]`)
110
112
  }
111
113
 
112
114
  let cliArgs = process.argv.slice(2)
@@ -198,7 +200,6 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
198
200
  )
199
201
  }
200
202
  }
201
-
202
203
  async function promptSelect<T>(
203
204
  title: string,
204
205
  items: T[],
@@ -559,18 +560,26 @@ function printTeamMembers(members: TeamMember[]) {
559
560
 
560
561
  const rows = members.map(member => ({
561
562
  id: member.id,
563
+ userId: member.user_id || '-',
562
564
  email: member.email || '-',
563
565
  role: member.role || '-',
564
566
  }))
565
567
 
566
568
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
569
+ const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length))
567
570
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
568
571
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
569
572
 
570
- console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`)
571
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`)
573
+ console.log(
574
+ `${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`
575
+ )
576
+ console.log(
577
+ `${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`
578
+ )
572
579
  rows.forEach(row => {
573
- console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`)
580
+ console.log(
581
+ `${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`
582
+ )
574
583
  })
575
584
  }
576
585
 
@@ -593,15 +602,34 @@ function printTaskStatusSummary(data: TaskStatusPayload) {
593
602
  }
594
603
  }
595
604
 
605
+ const DEFAULT_AUTH_CALLBACK_PORT = 43123
606
+
607
+ function resolveAuthCallbackPort(): number {
608
+ const envPort = process.env.ORIZU_AUTH_PORT
609
+ if (!envPort) {
610
+ return DEFAULT_AUTH_CALLBACK_PORT
611
+ }
612
+
613
+ const parsed = parseInt(envPort, 10)
614
+ if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
615
+ throw new Error(
616
+ `Invalid ORIZU_AUTH_PORT: '${envPort}'. Must be a number between 1024 and 65535.`
617
+ )
618
+ }
619
+
620
+ return parsed
621
+ }
622
+
596
623
  async function login() {
597
624
  const baseUrl = resolveLoginBaseUrl()
598
625
  const codeVerifier = createCodeVerifier()
599
626
  const codeChallenge = createCodeChallenge(codeVerifier)
627
+ const callbackPort = resolveAuthCallbackPort()
600
628
 
601
629
  const callbackCode = await new Promise<string>((resolve, reject) => {
602
630
  const server = createServer((request, response) => {
603
631
  try {
604
- const url = new URL(request.url || '/', 'http://127.0.0.1:43123')
632
+ const url = new URL(request.url || '/', `http://127.0.0.1:${callbackPort}`)
605
633
  const code = url.searchParams.get('code')
606
634
 
607
635
  if (!code) {
@@ -622,11 +650,19 @@ async function login() {
622
650
  }
623
651
  })
624
652
 
625
- server.on('error', reject)
653
+ server.on('error', (error: NodeJS.ErrnoException) => {
654
+ if (error.code === 'EADDRINUSE') {
655
+ reject(new Error(
656
+ `Port ${callbackPort} is already in use. Set ORIZU_AUTH_PORT to a different port (1024–65535) and retry.`
657
+ ))
658
+ } else {
659
+ reject(error)
660
+ }
661
+ })
626
662
 
627
- server.listen(43123, '127.0.0.1', async () => {
663
+ server.listen(callbackPort, '127.0.0.1', async () => {
628
664
  try {
629
- const redirectUri = 'http://127.0.0.1:43123/callback'
665
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`
630
666
  const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
631
667
  method: 'POST',
632
668
  headers: { 'Content-Type': 'application/json' },
@@ -921,12 +957,18 @@ async function linkAppDataset() {
921
957
  const project = getArg('--project')
922
958
  let appId = getArg('--app')
923
959
  const versionArg = getArg('--version')
924
- const versionNum = versionArg ? Number(versionArg) : undefined
960
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
961
+ const versionNum =
962
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : undefined
925
963
 
926
964
  if (!datasetId) {
927
965
  throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]')
928
966
  }
929
967
 
968
+ if (versionArg && versionNum === undefined) {
969
+ throw new Error('--version must be a positive integer')
970
+ }
971
+
930
972
  if (!appId) {
931
973
  const selected = await selectAppIdInteractively(project)
932
974
  appId = selected.appId
@@ -972,12 +1014,20 @@ async function createTask() {
972
1014
  const appId = getArg('--app')
973
1015
  const title = getArg('--title')
974
1016
  const assignees = parseCommaSeparated(getArg('--assignees'))
1017
+ const versionArg = getArg('--version')
975
1018
  const instructions = getArg('--instructions')
976
1019
  const labelsPerItemArg = getArg('--labels-per-item')
977
1020
  const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
1021
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
1022
+ const versionNum =
1023
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null
978
1024
 
979
1025
  if (!projectSlug || !datasetId || !appId || !title || assignees.length === 0) {
980
- throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userId1,userId2> [--instructions <text>] [--labels-per-item <n>]')
1026
+ throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]')
1027
+ }
1028
+
1029
+ if (versionArg && versionNum === null) {
1030
+ throw new Error('--version must be a positive integer')
981
1031
  }
982
1032
 
983
1033
  const response = await authedFetch('/api/cli/tasks', {
@@ -987,6 +1037,7 @@ async function createTask() {
987
1037
  projectSlug,
988
1038
  datasetId,
989
1039
  appId,
1040
+ versionNum,
990
1041
  title,
991
1042
  memberIds: assignees,
992
1043
  instructions,
@@ -995,15 +1046,59 @@ async function createTask() {
995
1046
  })
996
1047
 
997
1048
  if (!response.ok) {
998
- throw new Error(`Failed to create task: ${await response.text()}`)
1049
+ const cliError = await formatTaskCreateError(response)
1050
+ if (hasArg('--json')) {
1051
+ const payload = cliError.structuredPayload ?? { error: cliError.message }
1052
+ console.log(JSON.stringify({
1053
+ ...payload,
1054
+ httpStatus: cliError.httpStatus,
1055
+ }, null, 2))
1056
+ }
1057
+ throw cliError
999
1058
  }
1000
1059
 
1001
1060
  const data = await parseJsonResponse<{
1002
- task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
1061
+ task: {
1062
+ id: string
1063
+ title: string
1064
+ status: string
1065
+ requiredAssignmentsPerRow: number
1066
+ versionId: string
1067
+ versionNum: number
1068
+ }
1003
1069
  assignmentsCreated: number
1070
+ assignmentShortfall?: number
1071
+ warning?: string
1004
1072
  }>(response, 'Task create')
1073
+
1074
+ if (hasArg('--json')) {
1075
+ console.log(JSON.stringify({
1076
+ taskId: data.task.id,
1077
+ datasetId,
1078
+ versionId: data.task.versionId,
1079
+ versionNum: data.task.versionNum,
1080
+ taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
1081
+ title: data.task.title,
1082
+ status: data.task.status,
1083
+ requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
1084
+ assignmentsCreated: data.assignmentsCreated,
1085
+ ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
1086
+ ...(data.warning ? { warning: data.warning } : {}),
1087
+ }, null, 2))
1088
+ return
1089
+ }
1090
+
1091
+ const baseUrl = getBaseUrl()
1092
+ const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`
1005
1093
  console.log(
1006
- `Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`
1094
+ `Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
1095
+ `\n Task ID: ${data.task.id}` +
1096
+ `\n Dataset ID: ${datasetId}` +
1097
+ `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
1098
+ `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
1099
+ `\n Assignments: ${data.assignmentsCreated}` +
1100
+ (data.warning ? `\n Warning: ${data.warning}` : '') +
1101
+ `\n URL: ${taskUrl}`
1007
1102
  )
1008
1103
  }
1009
1104
 
@@ -1037,7 +1132,31 @@ async function taskStatus() {
1037
1132
 
1038
1133
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`)
1039
1134
  if (!response.ok) {
1040
- throw new Error(`Failed to fetch task status: ${await response.text()}`)
1135
+ const rawBody = await response.text()
1136
+ if (hasArg('--json')) {
1137
+ let errorPayload: Record<string, unknown> = { error: rawBody }
1138
+ try {
1139
+ const parsed = JSON.parse(rawBody)
1140
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1141
+ errorPayload = parsed as Record<string, unknown>
1142
+ }
1143
+ } catch {
1144
+ // keep raw body as error
1145
+ }
1146
+ console.log(JSON.stringify({
1147
+ ...errorPayload,
1148
+ httpStatus: response.status,
1149
+ }, null, 2))
1150
+ }
1151
+
1152
+ let errorMsg = rawBody
1153
+ try {
1154
+ const parsed = JSON.parse(rawBody)
1155
+ if (parsed && typeof parsed === 'object' && typeof parsed.error === 'string') {
1156
+ errorMsg = parsed.error
1157
+ }
1158
+ } catch { /* use rawBody as-is */ }
1159
+ throw new Error(`Failed to fetch task status: ${errorMsg}`)
1041
1160
  }
1042
1161
 
1043
1162
  const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
@@ -1049,6 +1168,97 @@ async function taskStatus() {
1049
1168
  printTaskStatusSummary(data)
1050
1169
  }
1051
1170
 
1171
+ async function updateTaskStatus(targetStatus: 'paused' | 'active') {
1172
+ const taskId = getArg('--task')
1173
+ if (!taskId) {
1174
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1175
+ throw new Error(`Usage: orizu tasks ${verb} --task <taskId>`)
1176
+ }
1177
+
1178
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`, {
1179
+ method: 'PATCH',
1180
+ headers: { 'Content-Type': 'application/json' },
1181
+ body: JSON.stringify({ status: targetStatus }),
1182
+ })
1183
+
1184
+ if (!response.ok) {
1185
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1186
+ throw new Error(`Failed to ${verb} task: ${await response.text()}`)
1187
+ }
1188
+
1189
+ const data = await parseJsonResponse<{ task: { id: string; status: string } }>(response, 'Task status update')
1190
+ const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused'
1191
+ console.log(`${action} task ${data.task.id} [${data.task.status}]`)
1192
+ }
1193
+
1194
+ interface AppDetailPayload {
1195
+ id: string
1196
+ name: string
1197
+ currentVersionNum: number | null
1198
+ currentVersion: {
1199
+ versionId: string
1200
+ versionNum: number
1201
+ inputJsonSchema: unknown
1202
+ outputJsonSchema: unknown
1203
+ } | null
1204
+ createdAt: string
1205
+ updatedAt: string
1206
+ projectId: string | null
1207
+ compatibleDatasetsCount: number
1208
+ totalDatasetsCount: number
1209
+ createdByName: string | null
1210
+ createdByEmail: string | null
1211
+ teamSlug: string
1212
+ teamName: string
1213
+ projectSlug: string
1214
+ projectName: string
1215
+ }
1216
+
1217
+ async function appDetail() {
1218
+ const appId = getArg('--app')
1219
+ const project = getArg('--project')
1220
+
1221
+ if (!appId) {
1222
+ throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]')
1223
+ }
1224
+
1225
+ const projectSlug = project || await resolveProjectSlug(null)
1226
+
1227
+ // Single fetch — the apps endpoint already returns full detail (ALI-544)
1228
+ const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`)
1229
+ if (!detailResponse.ok) {
1230
+ throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`)
1231
+ }
1232
+
1233
+ const detailData = await parseJsonResponse<{ apps: AppDetailPayload[] }>(detailResponse, 'App detail')
1234
+ const detail = detailData.apps.find(a => a.id === appId)
1235
+
1236
+ if (!detail) {
1237
+ throw new Error(`App '${appId}' not found in project '${projectSlug}'`)
1238
+ }
1239
+
1240
+ if (hasArg('--json')) {
1241
+ console.log(JSON.stringify(detail, null, 2))
1242
+ return
1243
+ }
1244
+
1245
+ console.log(`App: ${detail.name} (${detail.id})`)
1246
+ console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`)
1247
+ if (detail.currentVersion) {
1248
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`)
1249
+ console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`)
1250
+ console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`)
1251
+ } else {
1252
+ console.log(` Current version: none`)
1253
+ }
1254
+ console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`)
1255
+ if (detail.createdByEmail) {
1256
+ console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`)
1257
+ }
1258
+ console.log(` Created: ${detail.createdAt}`)
1259
+ console.log(` Updated: ${detail.updatedAt}`)
1260
+ }
1261
+
1052
1262
  async function listTeamMembers() {
1053
1263
  const teamSlug = await resolveTeamSlug(getArg('--team'))
1054
1264
 
@@ -1222,6 +1432,29 @@ async function downloadDataset() {
1222
1432
  console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`)
1223
1433
  }
1224
1434
 
1435
+ const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
1436
+ const APPEND_CHUNK_SIZE_ROWS = 500
1437
+
1438
+ async function appendChunk(
1439
+ datasetId: string,
1440
+ rows: Array<Record<string, unknown>>
1441
+ ): Promise<{ dataset: { id: string; name: string; rowCount: number }; appendedCount: number }> {
1442
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1443
+ method: 'POST',
1444
+ headers: { 'Content-Type': 'application/json' },
1445
+ body: JSON.stringify({ rows }),
1446
+ })
1447
+
1448
+ if (!response.ok) {
1449
+ throw new Error(`Append failed: ${await response.text()}`)
1450
+ }
1451
+
1452
+ return parseJsonResponse<{
1453
+ dataset: { id: string; name: string; rowCount: number }
1454
+ appendedCount: number
1455
+ }>(response, 'Dataset append')
1456
+ }
1457
+
1225
1458
  async function appendDatasetRows() {
1226
1459
  const projectArg = getArg('--project')
1227
1460
  const datasetInput = getDatasetReferenceInput()
@@ -1240,57 +1473,145 @@ async function appendDatasetRows() {
1240
1473
  }
1241
1474
 
1242
1475
  const file = expandHomePath(fileArg)
1476
+
1477
+ // Check file size before reading to prevent OOM on large files (ALI-565).
1478
+ // Wrap statSync in try/catch so missing/inaccessible files get friendly
1479
+ // errors instead of raw Node.js ENOENT/EPERM (ALI-554).
1480
+ let fileSizeBytes: number
1481
+ try {
1482
+ fileSizeBytes = statSync(file).size
1483
+ } catch (error) {
1484
+ const maybeError = error as NodeJS.ErrnoException
1485
+ if (maybeError.code === 'ENOENT') {
1486
+ throw new Error(
1487
+ `File not found: ${file}. Check the path and filename, then retry.`
1488
+ )
1489
+ }
1490
+ if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
1491
+ throw new Error(
1492
+ `Cannot read file: ${file}. Grant folder permission to your terminal app and retry.`
1493
+ )
1494
+ }
1495
+ throw new Error(`Failed to access file ${file}: ${maybeError.message}`)
1496
+ }
1497
+ if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
1498
+ const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1)
1499
+ throw new Error(
1500
+ `Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`
1501
+ )
1502
+ }
1503
+
1243
1504
  const { rows } = parseDatasetFile(file)
1244
1505
  if (!Array.isArray(rows) || rows.length === 0) {
1245
1506
  throw new Error('Dataset append file must contain at least one row')
1246
1507
  }
1247
1508
 
1509
+ if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1510
+ const data = await appendChunk(datasetId, rows)
1511
+ console.log(
1512
+ `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1513
+ )
1514
+ return
1515
+ }
1516
+
1517
+ // Chunked upload for large row counts (ALI-555: track partial progress)
1518
+ let totalAppended = 0
1519
+ let lastResult: { dataset: { id: string; name: string; rowCount: number } } | null = null
1520
+ const totalChunks = Math.ceil(rows.length / APPEND_CHUNK_SIZE_ROWS)
1521
+
1522
+ for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1523
+ const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS)
1524
+ const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1
1525
+
1526
+ console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`)
1527
+ try {
1528
+ const data = await appendChunk(datasetId, chunk)
1529
+ totalAppended += data.appendedCount
1530
+ lastResult = data
1531
+ } catch (error) {
1532
+ const msg = error instanceof Error ? error.message : String(error)
1533
+ throw new Error(
1534
+ `Chunk ${chunkIndex}/${totalChunks} failed: ${msg}\n` +
1535
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1536
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`
1537
+ )
1538
+ }
1539
+ }
1540
+
1541
+ if (lastResult) {
1542
+ console.log(
1543
+ `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1544
+ )
1545
+ }
1546
+ }
1547
+
1548
+ async function editDatasetRows() {
1549
+ const projectArg = getArg('--project')
1550
+ const datasetInput = getDatasetReferenceInput()
1551
+ const fileArg = getArg('--file')
1552
+
1553
+ if (!fileArg) {
1554
+ throw new Error('Usage: orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>')
1555
+ }
1556
+
1557
+ let datasetId: string
1558
+ if (datasetInput) {
1559
+ datasetId = parseDatasetReference(datasetInput).datasetId
1560
+ } else {
1561
+ const selected = await selectDatasetInteractively(projectArg)
1562
+ datasetId = selected.datasetId
1563
+ }
1564
+
1565
+ const file = expandHomePath(fileArg)
1566
+ const { rows } = parseDatasetFile(file)
1567
+ if (!Array.isArray(rows) || rows.length === 0) {
1568
+ throw new Error('Dataset edit file must contain at least one row')
1569
+ }
1570
+
1571
+ const normalizedRows = rows.map((row, index) => {
1572
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
1573
+ throw new Error(`Dataset edit file rows[${index}] must be an object`)
1574
+ }
1575
+
1576
+ const rowRecord = row as Record<string, unknown>
1577
+ const rowId = typeof rowRecord.id === 'string' ? rowRecord.id.trim() : ''
1578
+ if (!rowId) {
1579
+ throw new Error(`Dataset edit file rows[${index}] must include a non-empty string id`)
1580
+ }
1581
+
1582
+ return {
1583
+ ...rowRecord,
1584
+ id: rowId,
1585
+ }
1586
+ })
1587
+
1248
1588
  const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1249
- method: 'POST',
1589
+ method: 'PATCH',
1250
1590
  headers: { 'Content-Type': 'application/json' },
1251
- body: JSON.stringify({ rows }),
1591
+ body: JSON.stringify({ rows: normalizedRows }),
1252
1592
  })
1253
1593
 
1254
1594
  if (!response.ok) {
1255
- throw new Error(`Append failed: ${await response.text()}`)
1595
+ throw new Error(`Edit rows failed: ${await response.text()}`)
1256
1596
  }
1257
1597
 
1258
1598
  const data = await parseJsonResponse<{
1259
1599
  dataset: { id: string; name: string; rowCount: number }
1260
- appendedCount: number
1261
- }>(response, 'Dataset append')
1600
+ updatedCount: number
1601
+ }>(response, 'Dataset edit rows')
1262
1602
 
1263
1603
  console.log(
1264
- `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1604
+ `Updated ${data.updatedCount} rows in dataset ${data.dataset.name} (${data.dataset.id}). Current row count: ${data.dataset.rowCount}`
1265
1605
  )
1266
1606
  }
1267
1607
 
1268
- function parseCommaSeparatedIntegers(value: string | null): number[] {
1269
- if (!value) {
1270
- return []
1271
- }
1272
-
1273
- const rawItems = value
1274
- .split(',')
1275
- .map(item => item.trim())
1276
- .filter(Boolean)
1277
- const parsed = rawItems.map(item => Number(item))
1278
- const invalid = parsed.some(item => !Number.isInteger(item) || item < 0)
1279
- if (invalid) {
1280
- throw new Error('row-indices must be comma-separated non-negative integers')
1281
- }
1282
-
1283
- return parsed
1284
- }
1285
-
1286
1608
  async function deleteDatasetRows() {
1287
1609
  const projectArg = getArg('--project')
1288
1610
  const datasetInput = getDatasetReferenceInput()
1289
1611
  const rowIds = parseCommaSeparated(getArg('--row-ids'))
1290
- const rowIndices = parseCommaSeparatedIntegers(getArg('--row-indices'))
1291
1612
 
1292
- if (rowIds.length === 0 && rowIndices.length === 0) {
1293
- throw new Error('Usage: orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--row-ids <id1,id2>] [--row-indices <n1,n2>]')
1613
+ if (rowIds.length === 0) {
1614
+ throw new Error('Usage: orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>')
1294
1615
  }
1295
1616
 
1296
1617
  let datasetId: string
@@ -1306,7 +1627,6 @@ async function deleteDatasetRows() {
1306
1627
  headers: { 'Content-Type': 'application/json' },
1307
1628
  body: JSON.stringify({
1308
1629
  rowIds,
1309
- rowIndices,
1310
1630
  }),
1311
1631
  })
1312
1632
 
@@ -1324,6 +1644,81 @@ async function deleteDatasetRows() {
1324
1644
  )
1325
1645
  }
1326
1646
 
1647
+ async function lockDataset() {
1648
+ const projectArg = getArg('--project')
1649
+ const datasetInput = getDatasetReferenceInput()
1650
+ const reason = getArg('--reason')
1651
+
1652
+ let datasetId: string
1653
+ if (datasetInput) {
1654
+ datasetId = parseDatasetReference(datasetInput).datasetId
1655
+ } else {
1656
+ const selected = await selectDatasetInteractively(projectArg)
1657
+ datasetId = selected.datasetId
1658
+ }
1659
+
1660
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/lock`, {
1661
+ method: 'POST',
1662
+ headers: { 'Content-Type': 'application/json' },
1663
+ body: JSON.stringify(reason ? { reason } : {}),
1664
+ })
1665
+
1666
+ if (!response.ok) {
1667
+ throw new Error(`Lock failed: ${await response.text()}`)
1668
+ }
1669
+
1670
+ const data = await parseJsonResponse<{
1671
+ dataset: {
1672
+ id: string
1673
+ name: string
1674
+ rowCount: number
1675
+ lockedAt: string
1676
+ lockedBy: string | null
1677
+ }
1678
+ }>(response, 'Dataset lock')
1679
+
1680
+ console.log(
1681
+ `Locked dataset ${data.dataset.name} (${data.dataset.id}) at ${data.dataset.lockedAt}. Row count: ${data.dataset.rowCount}`
1682
+ )
1683
+ }
1684
+
1685
+ async function cloneDataset() {
1686
+ const projectArg = getArg('--project')
1687
+ const datasetInput = getDatasetReferenceInput()
1688
+ const name = getArg('--name')
1689
+
1690
+ let datasetId: string
1691
+ if (datasetInput) {
1692
+ datasetId = parseDatasetReference(datasetInput).datasetId
1693
+ } else {
1694
+ const selected = await selectDatasetInteractively(projectArg)
1695
+ datasetId = selected.datasetId
1696
+ }
1697
+
1698
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/clone`, {
1699
+ method: 'POST',
1700
+ headers: { 'Content-Type': 'application/json' },
1701
+ body: JSON.stringify(name ? { name } : {}),
1702
+ })
1703
+
1704
+ if (!response.ok) {
1705
+ throw new Error(`Clone failed: ${await response.text()}`)
1706
+ }
1707
+
1708
+ const data = await parseJsonResponse<{
1709
+ dataset: {
1710
+ id: string
1711
+ name: string
1712
+ rowCount: number
1713
+ parentDatasetId: string
1714
+ }
1715
+ }>(response, 'Dataset clone')
1716
+
1717
+ console.log(
1718
+ `Cloned dataset ${data.dataset.parentDatasetId} -> ${data.dataset.name} (${data.dataset.id}). Row count: ${data.dataset.rowCount}`
1719
+ )
1720
+ }
1721
+
1327
1722
  async function downloadAnnotations() {
1328
1723
  let taskId = getArg('--task')
1329
1724
  const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
@@ -1439,6 +1834,11 @@ async function main() {
1439
1834
  return
1440
1835
  }
1441
1836
 
1837
+ if (command === 'apps' && subcommand === 'detail') {
1838
+ await appDetail()
1839
+ return
1840
+ }
1841
+
1442
1842
  if (command === 'tasks' && subcommand === 'list') {
1443
1843
  await listTasks()
1444
1844
  return
@@ -1459,6 +1859,16 @@ async function main() {
1459
1859
  return
1460
1860
  }
1461
1861
 
1862
+ if (command === 'tasks' && subcommand === 'pause') {
1863
+ await updateTaskStatus('paused')
1864
+ return
1865
+ }
1866
+
1867
+ if (command === 'tasks' && subcommand === 'unpause') {
1868
+ await updateTaskStatus('active')
1869
+ return
1870
+ }
1871
+
1462
1872
  if (command === 'datasets' && subcommand === 'upload') {
1463
1873
  await uploadDataset()
1464
1874
  return
@@ -1474,11 +1884,26 @@ async function main() {
1474
1884
  return
1475
1885
  }
1476
1886
 
1887
+ if (command === 'datasets' && subcommand === 'edit-rows') {
1888
+ await editDatasetRows()
1889
+ return
1890
+ }
1891
+
1477
1892
  if (command === 'datasets' && subcommand === 'delete-rows') {
1478
1893
  await deleteDatasetRows()
1479
1894
  return
1480
1895
  }
1481
1896
 
1897
+ if (command === 'datasets' && subcommand === 'lock') {
1898
+ await lockDataset()
1899
+ return
1900
+ }
1901
+
1902
+ if (command === 'datasets' && subcommand === 'clone') {
1903
+ await cloneDataset()
1904
+ return
1905
+ }
1906
+
1482
1907
  if (command === 'tasks' && subcommand === 'export') {
1483
1908
  await downloadAnnotations()
1484
1909
  return