orizu 0.0.9 → 0.1.1

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,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from 'crypto'
3
- import { basename } from 'path'
3
+ import { basename, extname } 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
+ import { streamJsonlRowChunks } from './jsonl-stream.js'
11
12
  import { parseDatasetReference } from './dataset-download.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 {
@@ -65,6 +67,18 @@ interface DatasetSummary {
65
67
  teamSlug: string
66
68
  }
67
69
 
70
+ type DatasetUploadSourceType = 'csv' | 'json' | 'jsonl'
71
+
72
+ interface DatasetUploadResponse {
73
+ dataset: {
74
+ id: string
75
+ name: string
76
+ rowCount: number
77
+ sourceType: string
78
+ url?: string
79
+ }
80
+ }
81
+
68
82
  interface TeamMember {
69
83
  id: string
70
84
  user_id: string | null
@@ -106,7 +120,7 @@ interface TaskStatusPayload {
106
120
  }
107
121
 
108
122
  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 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>]`)
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>]`)
110
124
  }
111
125
 
112
126
  let cliArgs = process.argv.slice(2)
@@ -198,7 +212,6 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
198
212
  )
199
213
  }
200
214
  }
201
-
202
215
  async function promptSelect<T>(
203
216
  title: string,
204
217
  items: T[],
@@ -559,18 +572,26 @@ function printTeamMembers(members: TeamMember[]) {
559
572
 
560
573
  const rows = members.map(member => ({
561
574
  id: member.id,
575
+ userId: member.user_id || '-',
562
576
  email: member.email || '-',
563
577
  role: member.role || '-',
564
578
  }))
565
579
 
566
580
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
581
+ const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length))
567
582
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
568
583
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
569
584
 
570
- console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`)
571
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`)
585
+ console.log(
586
+ `${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`
587
+ )
588
+ console.log(
589
+ `${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`
590
+ )
572
591
  rows.forEach(row => {
573
- console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`)
592
+ console.log(
593
+ `${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`
594
+ )
574
595
  })
575
596
  }
576
597
 
@@ -593,15 +614,34 @@ function printTaskStatusSummary(data: TaskStatusPayload) {
593
614
  }
594
615
  }
595
616
 
617
+ const DEFAULT_AUTH_CALLBACK_PORT = 43123
618
+
619
+ function resolveAuthCallbackPort(): number {
620
+ const envPort = process.env.ORIZU_AUTH_PORT
621
+ if (!envPort) {
622
+ return DEFAULT_AUTH_CALLBACK_PORT
623
+ }
624
+
625
+ const parsed = parseInt(envPort, 10)
626
+ if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
627
+ throw new Error(
628
+ `Invalid ORIZU_AUTH_PORT: '${envPort}'. Must be a number between 1024 and 65535.`
629
+ )
630
+ }
631
+
632
+ return parsed
633
+ }
634
+
596
635
  async function login() {
597
636
  const baseUrl = resolveLoginBaseUrl()
598
637
  const codeVerifier = createCodeVerifier()
599
638
  const codeChallenge = createCodeChallenge(codeVerifier)
639
+ const callbackPort = resolveAuthCallbackPort()
600
640
 
601
641
  const callbackCode = await new Promise<string>((resolve, reject) => {
602
642
  const server = createServer((request, response) => {
603
643
  try {
604
- const url = new URL(request.url || '/', 'http://127.0.0.1:43123')
644
+ const url = new URL(request.url || '/', `http://127.0.0.1:${callbackPort}`)
605
645
  const code = url.searchParams.get('code')
606
646
 
607
647
  if (!code) {
@@ -622,11 +662,19 @@ async function login() {
622
662
  }
623
663
  })
624
664
 
625
- server.on('error', reject)
665
+ server.on('error', (error: NodeJS.ErrnoException) => {
666
+ if (error.code === 'EADDRINUSE') {
667
+ reject(new Error(
668
+ `Port ${callbackPort} is already in use. Set ORIZU_AUTH_PORT to a different port (1024–65535) and retry.`
669
+ ))
670
+ } else {
671
+ reject(error)
672
+ }
673
+ })
626
674
 
627
- server.listen(43123, '127.0.0.1', async () => {
675
+ server.listen(callbackPort, '127.0.0.1', async () => {
628
676
  try {
629
- const redirectUri = 'http://127.0.0.1:43123/callback'
677
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`
630
678
  const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
631
679
  method: 'POST',
632
680
  headers: { 'Content-Type': 'application/json' },
@@ -921,12 +969,18 @@ async function linkAppDataset() {
921
969
  const project = getArg('--project')
922
970
  let appId = getArg('--app')
923
971
  const versionArg = getArg('--version')
924
- const versionNum = versionArg ? Number(versionArg) : undefined
972
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
973
+ const versionNum =
974
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : undefined
925
975
 
926
976
  if (!datasetId) {
927
977
  throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]')
928
978
  }
929
979
 
980
+ if (versionArg && versionNum === undefined) {
981
+ throw new Error('--version must be a positive integer')
982
+ }
983
+
930
984
  if (!appId) {
931
985
  const selected = await selectAppIdInteractively(project)
932
986
  appId = selected.appId
@@ -972,12 +1026,20 @@ async function createTask() {
972
1026
  const appId = getArg('--app')
973
1027
  const title = getArg('--title')
974
1028
  const assignees = parseCommaSeparated(getArg('--assignees'))
1029
+ const versionArg = getArg('--version')
975
1030
  const instructions = getArg('--instructions')
976
1031
  const labelsPerItemArg = getArg('--labels-per-item')
977
1032
  const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
1033
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
1034
+ const versionNum =
1035
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null
978
1036
 
979
1037
  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>]')
1038
+ 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]')
1039
+ }
1040
+
1041
+ if (versionArg && versionNum === null) {
1042
+ throw new Error('--version must be a positive integer')
981
1043
  }
982
1044
 
983
1045
  const response = await authedFetch('/api/cli/tasks', {
@@ -987,6 +1049,7 @@ async function createTask() {
987
1049
  projectSlug,
988
1050
  datasetId,
989
1051
  appId,
1052
+ versionNum,
990
1053
  title,
991
1054
  memberIds: assignees,
992
1055
  instructions,
@@ -995,15 +1058,59 @@ async function createTask() {
995
1058
  })
996
1059
 
997
1060
  if (!response.ok) {
998
- throw new Error(`Failed to create task: ${await response.text()}`)
1061
+ const cliError = await formatTaskCreateError(response)
1062
+ if (hasArg('--json')) {
1063
+ const payload = cliError.structuredPayload ?? { error: cliError.message }
1064
+ console.log(JSON.stringify({
1065
+ ...payload,
1066
+ httpStatus: cliError.httpStatus,
1067
+ }, null, 2))
1068
+ }
1069
+ throw cliError
999
1070
  }
1000
1071
 
1001
1072
  const data = await parseJsonResponse<{
1002
- task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
1073
+ task: {
1074
+ id: string
1075
+ title: string
1076
+ status: string
1077
+ requiredAssignmentsPerRow: number
1078
+ versionId: string
1079
+ versionNum: number
1080
+ }
1003
1081
  assignmentsCreated: number
1082
+ assignmentShortfall?: number
1083
+ warning?: string
1004
1084
  }>(response, 'Task create')
1085
+
1086
+ if (hasArg('--json')) {
1087
+ console.log(JSON.stringify({
1088
+ taskId: data.task.id,
1089
+ datasetId,
1090
+ versionId: data.task.versionId,
1091
+ versionNum: data.task.versionNum,
1092
+ taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
1093
+ title: data.task.title,
1094
+ status: data.task.status,
1095
+ requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
1096
+ assignmentsCreated: data.assignmentsCreated,
1097
+ ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
1098
+ ...(data.warning ? { warning: data.warning } : {}),
1099
+ }, null, 2))
1100
+ return
1101
+ }
1102
+
1103
+ const baseUrl = getBaseUrl()
1104
+ const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`
1005
1105
  console.log(
1006
- `Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`
1106
+ `Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
1107
+ `\n Task ID: ${data.task.id}` +
1108
+ `\n Dataset ID: ${datasetId}` +
1109
+ `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
1110
+ `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
1111
+ `\n Assignments: ${data.assignmentsCreated}` +
1112
+ (data.warning ? `\n Warning: ${data.warning}` : '') +
1113
+ `\n URL: ${taskUrl}`
1007
1114
  )
1008
1115
  }
1009
1116
 
@@ -1037,7 +1144,31 @@ async function taskStatus() {
1037
1144
 
1038
1145
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`)
1039
1146
  if (!response.ok) {
1040
- throw new Error(`Failed to fetch task status: ${await response.text()}`)
1147
+ const rawBody = await response.text()
1148
+ if (hasArg('--json')) {
1149
+ let errorPayload: Record<string, unknown> = { error: rawBody }
1150
+ try {
1151
+ const parsed = JSON.parse(rawBody)
1152
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1153
+ errorPayload = parsed as Record<string, unknown>
1154
+ }
1155
+ } catch {
1156
+ // keep raw body as error
1157
+ }
1158
+ console.log(JSON.stringify({
1159
+ ...errorPayload,
1160
+ httpStatus: response.status,
1161
+ }, null, 2))
1162
+ }
1163
+
1164
+ let errorMsg = rawBody
1165
+ try {
1166
+ const parsed = JSON.parse(rawBody)
1167
+ if (parsed && typeof parsed === 'object' && typeof parsed.error === 'string') {
1168
+ errorMsg = parsed.error
1169
+ }
1170
+ } catch { /* use rawBody as-is */ }
1171
+ throw new Error(`Failed to fetch task status: ${errorMsg}`)
1041
1172
  }
1042
1173
 
1043
1174
  const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
@@ -1049,6 +1180,97 @@ async function taskStatus() {
1049
1180
  printTaskStatusSummary(data)
1050
1181
  }
1051
1182
 
1183
+ async function updateTaskStatus(targetStatus: 'paused' | 'active') {
1184
+ const taskId = getArg('--task')
1185
+ if (!taskId) {
1186
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1187
+ throw new Error(`Usage: orizu tasks ${verb} --task <taskId>`)
1188
+ }
1189
+
1190
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`, {
1191
+ method: 'PATCH',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify({ status: targetStatus }),
1194
+ })
1195
+
1196
+ if (!response.ok) {
1197
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1198
+ throw new Error(`Failed to ${verb} task: ${await response.text()}`)
1199
+ }
1200
+
1201
+ const data = await parseJsonResponse<{ task: { id: string; status: string } }>(response, 'Task status update')
1202
+ const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused'
1203
+ console.log(`${action} task ${data.task.id} [${data.task.status}]`)
1204
+ }
1205
+
1206
+ interface AppDetailPayload {
1207
+ id: string
1208
+ name: string
1209
+ currentVersionNum: number | null
1210
+ currentVersion: {
1211
+ versionId: string
1212
+ versionNum: number
1213
+ inputJsonSchema: unknown
1214
+ outputJsonSchema: unknown
1215
+ } | null
1216
+ createdAt: string
1217
+ updatedAt: string
1218
+ projectId: string | null
1219
+ compatibleDatasetsCount: number
1220
+ totalDatasetsCount: number
1221
+ createdByName: string | null
1222
+ createdByEmail: string | null
1223
+ teamSlug: string
1224
+ teamName: string
1225
+ projectSlug: string
1226
+ projectName: string
1227
+ }
1228
+
1229
+ async function appDetail() {
1230
+ const appId = getArg('--app')
1231
+ const project = getArg('--project')
1232
+
1233
+ if (!appId) {
1234
+ throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]')
1235
+ }
1236
+
1237
+ const projectSlug = project || await resolveProjectSlug(null)
1238
+
1239
+ // Single fetch — the apps endpoint already returns full detail (ALI-544)
1240
+ const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`)
1241
+ if (!detailResponse.ok) {
1242
+ throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`)
1243
+ }
1244
+
1245
+ const detailData = await parseJsonResponse<{ apps: AppDetailPayload[] }>(detailResponse, 'App detail')
1246
+ const detail = detailData.apps.find(a => a.id === appId)
1247
+
1248
+ if (!detail) {
1249
+ throw new Error(`App '${appId}' not found in project '${projectSlug}'`)
1250
+ }
1251
+
1252
+ if (hasArg('--json')) {
1253
+ console.log(JSON.stringify(detail, null, 2))
1254
+ return
1255
+ }
1256
+
1257
+ console.log(`App: ${detail.name} (${detail.id})`)
1258
+ console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`)
1259
+ if (detail.currentVersion) {
1260
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`)
1261
+ console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`)
1262
+ console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`)
1263
+ } else {
1264
+ console.log(` Current version: none`)
1265
+ }
1266
+ console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`)
1267
+ if (detail.createdByEmail) {
1268
+ console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`)
1269
+ }
1270
+ console.log(` Created: ${detail.createdAt}`)
1271
+ console.log(` Updated: ${detail.updatedAt}`)
1272
+ }
1273
+
1052
1274
  async function listTeamMembers() {
1053
1275
  const teamSlug = await resolveTeamSlug(getArg('--team'))
1054
1276
 
@@ -1130,21 +1352,12 @@ async function changeTeamMemberRole() {
1130
1352
  console.log(`Updated ${member.email} role to ${role}`)
1131
1353
  }
1132
1354
 
1133
- async function uploadDataset() {
1134
- const projectArg = getArg('--project')
1135
- const fileArg = getArg('--file')
1136
- const name = getArg('--name')
1137
-
1138
- if (!fileArg) {
1139
- throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
1140
- }
1141
-
1142
- const file = expandHomePath(fileArg)
1143
- const project = await resolveProjectSlug(projectArg)
1144
-
1145
- const { rows, sourceType } = parseDatasetFile(file)
1146
- const datasetName = name || basename(file)
1147
-
1355
+ async function createDatasetFromRows(
1356
+ project: string,
1357
+ name: string,
1358
+ sourceType: DatasetUploadSourceType,
1359
+ rows: Array<Record<string, unknown>>
1360
+ ): Promise<DatasetUploadResponse> {
1148
1361
  const response = await authedFetch('/api/cli/datasets/upload', {
1149
1362
  method: 'POST',
1150
1363
  headers: {
@@ -1152,7 +1365,7 @@ async function uploadDataset() {
1152
1365
  },
1153
1366
  body: JSON.stringify({
1154
1367
  projectSlug: project,
1155
- name: datasetName,
1368
+ name,
1156
1369
  rows,
1157
1370
  sourceType,
1158
1371
  }),
@@ -1163,9 +1376,104 @@ async function uploadDataset() {
1163
1376
  throw new Error(`Upload failed: ${body.error}`)
1164
1377
  }
1165
1378
 
1166
- const data = await parseJsonResponse<{
1167
- dataset: { id: string; name: string; rowCount: number; sourceType: string; url?: string }
1168
- }>(response, 'Dataset upload')
1379
+ return parseJsonResponse<DatasetUploadResponse>(response, 'Dataset upload')
1380
+ }
1381
+
1382
+ async function uploadJsonlDatasetInChunks(
1383
+ file: string,
1384
+ project: string,
1385
+ datasetName: string
1386
+ ) {
1387
+ let dataset: DatasetUploadResponse['dataset'] | null = null
1388
+ let totalUploaded = 0
1389
+ let chunkIndex = 0
1390
+ const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]()
1391
+
1392
+ while (true) {
1393
+ let nextChunk: IteratorResult<Array<Record<string, unknown>>>
1394
+ try {
1395
+ nextChunk = await chunks.next()
1396
+ } catch (error) {
1397
+ const message = error instanceof Error ? error.message : String(error)
1398
+ if (!dataset) {
1399
+ throw new Error(message)
1400
+ }
1401
+
1402
+ throw new Error(
1403
+ `Upload stopped while reading the next JSONL chunk: ${message}\n` +
1404
+ `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
1405
+ `Fix the file, remove the first ${totalUploaded} rows, and run ` +
1406
+ `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`
1407
+ )
1408
+ }
1409
+
1410
+ if (nextChunk.done) {
1411
+ break
1412
+ }
1413
+
1414
+ const chunk = nextChunk.value
1415
+ chunkIndex += 1
1416
+ console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1417
+
1418
+ try {
1419
+ if (!dataset) {
1420
+ const data = await createDatasetFromRows(project, datasetName, 'jsonl', chunk)
1421
+ dataset = data.dataset
1422
+ totalUploaded = data.dataset.rowCount
1423
+ continue
1424
+ }
1425
+
1426
+ const data = await appendChunk(dataset.id, chunk)
1427
+ totalUploaded += data.appendedCount
1428
+ dataset = {
1429
+ ...dataset,
1430
+ rowCount: data.dataset.rowCount,
1431
+ }
1432
+ } catch (error) {
1433
+ const message = error instanceof Error ? error.message : String(error)
1434
+ if (!dataset) {
1435
+ throw new Error(message)
1436
+ }
1437
+
1438
+ throw new Error(
1439
+ `Chunk ${chunkIndex} failed: ${message}\n` +
1440
+ `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
1441
+ `To retry, remove the first ${totalUploaded} rows from your file and run ` +
1442
+ `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`
1443
+ )
1444
+ }
1445
+ }
1446
+
1447
+ if (!dataset) {
1448
+ throw new Error('Dataset file contains no rows')
1449
+ }
1450
+
1451
+ console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`)
1452
+ if (dataset.url) {
1453
+ console.log(`View dataset: ${formatTerminalLink(dataset.url)}`)
1454
+ }
1455
+ }
1456
+
1457
+ async function uploadDataset() {
1458
+ const projectArg = getArg('--project')
1459
+ const fileArg = getArg('--file')
1460
+ const name = getArg('--name')
1461
+
1462
+ if (!fileArg) {
1463
+ throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
1464
+ }
1465
+
1466
+ const file = expandHomePath(fileArg)
1467
+ const project = await resolveProjectSlug(projectArg)
1468
+ const datasetName = name || basename(file)
1469
+
1470
+ if (extname(file).toLowerCase() === '.jsonl') {
1471
+ await uploadJsonlDatasetInChunks(file, project, datasetName)
1472
+ return
1473
+ }
1474
+
1475
+ const { rows, sourceType } = parseDatasetFile(file)
1476
+ const data = await createDatasetFromRows(project, datasetName, sourceType, rows)
1169
1477
 
1170
1478
  console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`)
1171
1479
  if (data.dataset.url) {
@@ -1222,6 +1530,79 @@ async function downloadDataset() {
1222
1530
  console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`)
1223
1531
  }
1224
1532
 
1533
+ const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
1534
+ const APPEND_CHUNK_SIZE_ROWS = 500
1535
+
1536
+ async function appendChunk(
1537
+ datasetId: string,
1538
+ rows: Array<Record<string, unknown>>
1539
+ ): Promise<{ dataset: { id: string; name: string; rowCount: number }; appendedCount: number }> {
1540
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1541
+ method: 'POST',
1542
+ headers: { 'Content-Type': 'application/json' },
1543
+ body: JSON.stringify({ rows }),
1544
+ })
1545
+
1546
+ if (!response.ok) {
1547
+ throw new Error(`Append failed: ${await response.text()}`)
1548
+ }
1549
+
1550
+ return parseJsonResponse<{
1551
+ dataset: { id: string; name: string; rowCount: number }
1552
+ appendedCount: number
1553
+ }>(response, 'Dataset append')
1554
+ }
1555
+
1556
+ async function appendJsonlDatasetRowsInChunks(datasetId: string, file: string) {
1557
+ let totalAppended = 0
1558
+ let lastResult: { dataset: { id: string; name: string; rowCount: number } } | null = null
1559
+ let chunkIndex = 0
1560
+ const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]()
1561
+
1562
+ while (true) {
1563
+ let nextChunk: IteratorResult<Array<Record<string, unknown>>>
1564
+ try {
1565
+ nextChunk = await chunks.next()
1566
+ } catch (error) {
1567
+ const message = error instanceof Error ? error.message : String(error)
1568
+ throw new Error(
1569
+ `Append stopped while reading the next JSONL chunk: ${message}\n` +
1570
+ `${totalAppended} rows from ${chunkIndex} chunk(s) were already appended. ` +
1571
+ `Fix the file, remove the first ${totalAppended} rows, and re-run the command.`
1572
+ )
1573
+ }
1574
+
1575
+ if (nextChunk.done) {
1576
+ break
1577
+ }
1578
+
1579
+ const chunk = nextChunk.value
1580
+ chunkIndex += 1
1581
+ console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`)
1582
+
1583
+ try {
1584
+ const data = await appendChunk(datasetId, chunk)
1585
+ totalAppended += data.appendedCount
1586
+ lastResult = data
1587
+ } catch (error) {
1588
+ const message = error instanceof Error ? error.message : String(error)
1589
+ throw new Error(
1590
+ `Chunk ${chunkIndex} failed: ${message}\n` +
1591
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1592
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`
1593
+ )
1594
+ }
1595
+ }
1596
+
1597
+ if (!lastResult) {
1598
+ throw new Error('Dataset append file must contain at least one row')
1599
+ }
1600
+
1601
+ console.log(
1602
+ `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1603
+ )
1604
+ }
1605
+
1225
1606
  async function appendDatasetRows() {
1226
1607
  const projectArg = getArg('--project')
1227
1608
  const datasetInput = getDatasetReferenceInput()
@@ -1240,29 +1621,81 @@ async function appendDatasetRows() {
1240
1621
  }
1241
1622
 
1242
1623
  const file = expandHomePath(fileArg)
1624
+
1625
+ if (extname(file).toLowerCase() === '.jsonl') {
1626
+ await appendJsonlDatasetRowsInChunks(datasetId, file)
1627
+ return
1628
+ }
1629
+
1630
+ // Check file size before reading to prevent OOM on large files (ALI-565).
1631
+ // Wrap statSync in try/catch so missing/inaccessible files get friendly
1632
+ // errors instead of raw Node.js ENOENT/EPERM (ALI-554).
1633
+ let fileSizeBytes: number
1634
+ try {
1635
+ fileSizeBytes = statSync(file).size
1636
+ } catch (error) {
1637
+ const maybeError = error as NodeJS.ErrnoException
1638
+ if (maybeError.code === 'ENOENT') {
1639
+ throw new Error(
1640
+ `File not found: ${file}. Check the path and filename, then retry.`
1641
+ )
1642
+ }
1643
+ if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
1644
+ throw new Error(
1645
+ `Cannot read file: ${file}. Grant folder permission to your terminal app and retry.`
1646
+ )
1647
+ }
1648
+ throw new Error(`Failed to access file ${file}: ${maybeError.message}`)
1649
+ }
1650
+ if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
1651
+ const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1)
1652
+ throw new Error(
1653
+ `Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`
1654
+ )
1655
+ }
1656
+
1243
1657
  const { rows } = parseDatasetFile(file)
1244
1658
  if (!Array.isArray(rows) || rows.length === 0) {
1245
1659
  throw new Error('Dataset append file must contain at least one row')
1246
1660
  }
1247
1661
 
1248
- const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1249
- method: 'POST',
1250
- headers: { 'Content-Type': 'application/json' },
1251
- body: JSON.stringify({ rows }),
1252
- })
1253
-
1254
- if (!response.ok) {
1255
- throw new Error(`Append failed: ${await response.text()}`)
1662
+ if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1663
+ const data = await appendChunk(datasetId, rows)
1664
+ console.log(
1665
+ `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1666
+ )
1667
+ return
1256
1668
  }
1257
1669
 
1258
- const data = await parseJsonResponse<{
1259
- dataset: { id: string; name: string; rowCount: number }
1260
- appendedCount: number
1261
- }>(response, 'Dataset append')
1670
+ // Chunked upload for large row counts (ALI-555: track partial progress)
1671
+ let totalAppended = 0
1672
+ let lastResult: { dataset: { id: string; name: string; rowCount: number } } | null = null
1673
+ const totalChunks = Math.ceil(rows.length / APPEND_CHUNK_SIZE_ROWS)
1262
1674
 
1263
- console.log(
1264
- `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1265
- )
1675
+ for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1676
+ const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS)
1677
+ const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1
1678
+
1679
+ console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`)
1680
+ try {
1681
+ const data = await appendChunk(datasetId, chunk)
1682
+ totalAppended += data.appendedCount
1683
+ lastResult = data
1684
+ } catch (error) {
1685
+ const msg = error instanceof Error ? error.message : String(error)
1686
+ throw new Error(
1687
+ `Chunk ${chunkIndex}/${totalChunks} failed: ${msg}\n` +
1688
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1689
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`
1690
+ )
1691
+ }
1692
+ }
1693
+
1694
+ if (lastResult) {
1695
+ console.log(
1696
+ `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1697
+ )
1698
+ }
1266
1699
  }
1267
1700
 
1268
1701
  async function editDatasetRows() {
@@ -1554,6 +1987,11 @@ async function main() {
1554
1987
  return
1555
1988
  }
1556
1989
 
1990
+ if (command === 'apps' && subcommand === 'detail') {
1991
+ await appDetail()
1992
+ return
1993
+ }
1994
+
1557
1995
  if (command === 'tasks' && subcommand === 'list') {
1558
1996
  await listTasks()
1559
1997
  return
@@ -1574,6 +2012,16 @@ async function main() {
1574
2012
  return
1575
2013
  }
1576
2014
 
2015
+ if (command === 'tasks' && subcommand === 'pause') {
2016
+ await updateTaskStatus('paused')
2017
+ return
2018
+ }
2019
+
2020
+ if (command === 'tasks' && subcommand === 'unpause') {
2021
+ await updateTaskStatus('active')
2022
+ return
2023
+ }
2024
+
1577
2025
  if (command === 'datasets' && subcommand === 'upload') {
1578
2026
  await uploadDataset()
1579
2027
  return