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.
@@ -68,14 +68,22 @@ export function loadCredentialsConfig() {
68
68
  return null;
69
69
  }
70
70
  const raw = readFileSync(path, 'utf-8');
71
- const parsed = JSON.parse(raw);
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(raw);
74
+ }
75
+ catch {
76
+ console.warn('Warning: credentials file contains invalid JSON — please re-login with `orizu login`');
77
+ return null;
78
+ }
72
79
  if (isStoredCredentialsV2(parsed)) {
73
80
  return parsed;
74
81
  }
75
82
  if (isStoredCredentialsV1(parsed)) {
76
83
  return migrateToV2(parsed);
77
84
  }
78
- throw new Error('Invalid credentials file format.');
85
+ console.warn('Warning: credentials file has unrecognized format — please re-login with `orizu login`');
86
+ return null;
79
87
  }
80
88
  export function getServerCredentials(baseUrl) {
81
89
  const config = loadCredentialsConfig();
@@ -0,0 +1,20 @@
1
+ export async function extractErrorMessage(response) {
2
+ const contentType = response.headers.get('content-type') || '';
3
+ const rawBody = await response.text();
4
+ if (contentType.includes('application/json')) {
5
+ try {
6
+ const payload = JSON.parse(rawBody);
7
+ if (payload &&
8
+ typeof payload === 'object' &&
9
+ !Array.isArray(payload) &&
10
+ typeof payload.error === 'string' &&
11
+ payload.error.length > 0) {
12
+ return payload.error;
13
+ }
14
+ }
15
+ catch {
16
+ // Fall through to raw body
17
+ }
18
+ }
19
+ return rawBody;
20
+ }
package/dist/index.js CHANGED
@@ -1,18 +1,20 @@
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
  function printUsage() {
15
- 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>]`);
17
+ 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>]`);
16
18
  }
17
19
  let cliArgs = process.argv.slice(2);
18
20
  function getArg(name) {
@@ -313,16 +315,18 @@ function printTeamMembers(members) {
313
315
  }
314
316
  const rows = members.map(member => ({
315
317
  id: member.id,
318
+ userId: member.user_id || '-',
316
319
  email: member.email || '-',
317
320
  role: member.role || '-',
318
321
  }));
319
322
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length));
323
+ const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length));
320
324
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length));
321
325
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length));
322
- console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`);
323
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`);
326
+ console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`);
327
+ console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`);
324
328
  rows.forEach(row => {
325
- console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`);
329
+ console.log(`${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`);
326
330
  });
327
331
  }
328
332
  function printTaskStatusSummary(data) {
@@ -340,14 +344,27 @@ function printTaskStatusSummary(data) {
340
344
  });
341
345
  }
342
346
  }
347
+ const DEFAULT_AUTH_CALLBACK_PORT = 43123;
348
+ function resolveAuthCallbackPort() {
349
+ const envPort = process.env.ORIZU_AUTH_PORT;
350
+ if (!envPort) {
351
+ return DEFAULT_AUTH_CALLBACK_PORT;
352
+ }
353
+ const parsed = parseInt(envPort, 10);
354
+ if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
355
+ throw new Error(`Invalid ORIZU_AUTH_PORT: '${envPort}'. Must be a number between 1024 and 65535.`);
356
+ }
357
+ return parsed;
358
+ }
343
359
  async function login() {
344
360
  const baseUrl = resolveLoginBaseUrl();
345
361
  const codeVerifier = createCodeVerifier();
346
362
  const codeChallenge = createCodeChallenge(codeVerifier);
363
+ const callbackPort = resolveAuthCallbackPort();
347
364
  const callbackCode = await new Promise((resolve, reject) => {
348
365
  const server = createServer((request, response) => {
349
366
  try {
350
- const url = new URL(request.url || '/', 'http://127.0.0.1:43123');
367
+ const url = new URL(request.url || '/', `http://127.0.0.1:${callbackPort}`);
351
368
  const code = url.searchParams.get('code');
352
369
  if (!code) {
353
370
  response.statusCode = 400;
@@ -365,10 +382,17 @@ async function login() {
365
382
  reject(error);
366
383
  }
367
384
  });
368
- server.on('error', reject);
369
- server.listen(43123, '127.0.0.1', async () => {
385
+ server.on('error', (error) => {
386
+ if (error.code === 'EADDRINUSE') {
387
+ reject(new Error(`Port ${callbackPort} is already in use. Set ORIZU_AUTH_PORT to a different port (1024–65535) and retry.`));
388
+ }
389
+ else {
390
+ reject(error);
391
+ }
392
+ });
393
+ server.listen(callbackPort, '127.0.0.1', async () => {
370
394
  try {
371
- const redirectUri = 'http://127.0.0.1:43123/callback';
395
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`;
372
396
  const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
373
397
  method: 'POST',
374
398
  headers: { 'Content-Type': 'application/json' },
@@ -601,10 +625,14 @@ async function linkAppDataset() {
601
625
  const project = getArg('--project');
602
626
  let appId = getArg('--app');
603
627
  const versionArg = getArg('--version');
604
- const versionNum = versionArg ? Number(versionArg) : undefined;
628
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN;
629
+ const versionNum = Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : undefined;
605
630
  if (!datasetId) {
606
631
  throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]');
607
632
  }
633
+ if (versionArg && versionNum === undefined) {
634
+ throw new Error('--version must be a positive integer');
635
+ }
608
636
  if (!appId) {
609
637
  const selected = await selectAppIdInteractively(project);
610
638
  appId = selected.appId;
@@ -638,11 +666,17 @@ async function createTask() {
638
666
  const appId = getArg('--app');
639
667
  const title = getArg('--title');
640
668
  const assignees = parseCommaSeparated(getArg('--assignees'));
669
+ const versionArg = getArg('--version');
641
670
  const instructions = getArg('--instructions');
642
671
  const labelsPerItemArg = getArg('--labels-per-item');
643
672
  const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1;
673
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN;
674
+ const versionNum = Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null;
644
675
  if (!projectSlug || !datasetId || !appId || !title || assignees.length === 0) {
645
- 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>]');
676
+ 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]');
677
+ }
678
+ if (versionArg && versionNum === null) {
679
+ throw new Error('--version must be a positive integer');
646
680
  }
647
681
  const response = await authedFetch('/api/cli/tasks', {
648
682
  method: 'POST',
@@ -651,6 +685,7 @@ async function createTask() {
651
685
  projectSlug,
652
686
  datasetId,
653
687
  appId,
688
+ versionNum,
654
689
  title,
655
690
  memberIds: assignees,
656
691
  instructions,
@@ -658,10 +693,43 @@ async function createTask() {
658
693
  }),
659
694
  });
660
695
  if (!response.ok) {
661
- throw new Error(`Failed to create task: ${await response.text()}`);
696
+ const cliError = await formatTaskCreateError(response);
697
+ if (hasArg('--json')) {
698
+ const payload = cliError.structuredPayload ?? { error: cliError.message };
699
+ console.log(JSON.stringify({
700
+ ...payload,
701
+ httpStatus: cliError.httpStatus,
702
+ }, null, 2));
703
+ }
704
+ throw cliError;
662
705
  }
663
706
  const data = await parseJsonResponse(response, 'Task create');
664
- console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`);
707
+ if (hasArg('--json')) {
708
+ console.log(JSON.stringify({
709
+ taskId: data.task.id,
710
+ datasetId,
711
+ versionId: data.task.versionId,
712
+ versionNum: data.task.versionNum,
713
+ taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
714
+ title: data.task.title,
715
+ status: data.task.status,
716
+ requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
717
+ assignmentsCreated: data.assignmentsCreated,
718
+ ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
719
+ ...(data.warning ? { warning: data.warning } : {}),
720
+ }, null, 2));
721
+ return;
722
+ }
723
+ const baseUrl = getBaseUrl();
724
+ const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`;
725
+ console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
726
+ `\n Task ID: ${data.task.id}` +
727
+ `\n Dataset ID: ${datasetId}` +
728
+ `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
729
+ `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
730
+ `\n Assignments: ${data.assignmentsCreated}` +
731
+ (data.warning ? `\n Warning: ${data.warning}` : '') +
732
+ `\n URL: ${taskUrl}`);
665
733
  }
666
734
  async function assignTask() {
667
735
  const taskId = getArg('--task');
@@ -687,7 +755,32 @@ async function taskStatus() {
687
755
  }
688
756
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`);
689
757
  if (!response.ok) {
690
- throw new Error(`Failed to fetch task status: ${await response.text()}`);
758
+ const rawBody = await response.text();
759
+ if (hasArg('--json')) {
760
+ let errorPayload = { error: rawBody };
761
+ try {
762
+ const parsed = JSON.parse(rawBody);
763
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
764
+ errorPayload = parsed;
765
+ }
766
+ }
767
+ catch {
768
+ // keep raw body as error
769
+ }
770
+ console.log(JSON.stringify({
771
+ ...errorPayload,
772
+ httpStatus: response.status,
773
+ }, null, 2));
774
+ }
775
+ let errorMsg = rawBody;
776
+ try {
777
+ const parsed = JSON.parse(rawBody);
778
+ if (parsed && typeof parsed === 'object' && typeof parsed.error === 'string') {
779
+ errorMsg = parsed.error;
780
+ }
781
+ }
782
+ catch { /* use rawBody as-is */ }
783
+ throw new Error(`Failed to fetch task status: ${errorMsg}`);
691
784
  }
692
785
  const data = await parseJsonResponse(response, 'Task status');
693
786
  if (hasArg('--json')) {
@@ -696,6 +789,63 @@ async function taskStatus() {
696
789
  }
697
790
  printTaskStatusSummary(data);
698
791
  }
792
+ async function updateTaskStatus(targetStatus) {
793
+ const taskId = getArg('--task');
794
+ if (!taskId) {
795
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause';
796
+ throw new Error(`Usage: orizu tasks ${verb} --task <taskId>`);
797
+ }
798
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`, {
799
+ method: 'PATCH',
800
+ headers: { 'Content-Type': 'application/json' },
801
+ body: JSON.stringify({ status: targetStatus }),
802
+ });
803
+ if (!response.ok) {
804
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause';
805
+ throw new Error(`Failed to ${verb} task: ${await response.text()}`);
806
+ }
807
+ const data = await parseJsonResponse(response, 'Task status update');
808
+ const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused';
809
+ console.log(`${action} task ${data.task.id} [${data.task.status}]`);
810
+ }
811
+ async function appDetail() {
812
+ const appId = getArg('--app');
813
+ const project = getArg('--project');
814
+ if (!appId) {
815
+ throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]');
816
+ }
817
+ const projectSlug = project || await resolveProjectSlug(null);
818
+ // Single fetch — the apps endpoint already returns full detail (ALI-544)
819
+ const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`);
820
+ if (!detailResponse.ok) {
821
+ throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`);
822
+ }
823
+ const detailData = await parseJsonResponse(detailResponse, 'App detail');
824
+ const detail = detailData.apps.find(a => a.id === appId);
825
+ if (!detail) {
826
+ throw new Error(`App '${appId}' not found in project '${projectSlug}'`);
827
+ }
828
+ if (hasArg('--json')) {
829
+ console.log(JSON.stringify(detail, null, 2));
830
+ return;
831
+ }
832
+ console.log(`App: ${detail.name} (${detail.id})`);
833
+ console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`);
834
+ if (detail.currentVersion) {
835
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`);
836
+ console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`);
837
+ console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`);
838
+ }
839
+ else {
840
+ console.log(` Current version: none`);
841
+ }
842
+ console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`);
843
+ if (detail.createdByEmail) {
844
+ console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`);
845
+ }
846
+ console.log(` Created: ${detail.createdAt}`);
847
+ console.log(` Updated: ${detail.updatedAt}`);
848
+ }
699
849
  async function listTeamMembers() {
700
850
  const teamSlug = await resolveTeamSlug(getArg('--team'));
701
851
  printTeamMembers(await fetchTeamMembers(teamSlug));
@@ -759,17 +909,7 @@ async function changeTeamMemberRole() {
759
909
  }
760
910
  console.log(`Updated ${member.email} role to ${role}`);
761
911
  }
762
- async function uploadDataset() {
763
- const projectArg = getArg('--project');
764
- const fileArg = getArg('--file');
765
- const name = getArg('--name');
766
- if (!fileArg) {
767
- throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]');
768
- }
769
- const file = expandHomePath(fileArg);
770
- const project = await resolveProjectSlug(projectArg);
771
- const { rows, sourceType } = parseDatasetFile(file);
772
- const datasetName = name || basename(file);
912
+ async function createDatasetFromRows(project, name, sourceType, rows) {
773
913
  const response = await authedFetch('/api/cli/datasets/upload', {
774
914
  method: 'POST',
775
915
  headers: {
@@ -777,7 +917,7 @@ async function uploadDataset() {
777
917
  },
778
918
  body: JSON.stringify({
779
919
  projectSlug: project,
780
- name: datasetName,
920
+ name,
781
921
  rows,
782
922
  sourceType,
783
923
  }),
@@ -786,7 +926,83 @@ async function uploadDataset() {
786
926
  const body = await parseJsonResponse(response, 'Dataset upload');
787
927
  throw new Error(`Upload failed: ${body.error}`);
788
928
  }
789
- const data = await parseJsonResponse(response, 'Dataset upload');
929
+ return parseJsonResponse(response, 'Dataset upload');
930
+ }
931
+ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
932
+ let dataset = null;
933
+ let totalUploaded = 0;
934
+ let chunkIndex = 0;
935
+ const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]();
936
+ while (true) {
937
+ let nextChunk;
938
+ try {
939
+ nextChunk = await chunks.next();
940
+ }
941
+ catch (error) {
942
+ const message = error instanceof Error ? error.message : String(error);
943
+ if (!dataset) {
944
+ throw new Error(message);
945
+ }
946
+ throw new Error(`Upload stopped while reading the next JSONL chunk: ${message}\n` +
947
+ `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
948
+ `Fix the file, remove the first ${totalUploaded} rows, and run ` +
949
+ `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
950
+ }
951
+ if (nextChunk.done) {
952
+ break;
953
+ }
954
+ const chunk = nextChunk.value;
955
+ chunkIndex += 1;
956
+ console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
957
+ try {
958
+ if (!dataset) {
959
+ const data = await createDatasetFromRows(project, datasetName, 'jsonl', chunk);
960
+ dataset = data.dataset;
961
+ totalUploaded = data.dataset.rowCount;
962
+ continue;
963
+ }
964
+ const data = await appendChunk(dataset.id, chunk);
965
+ totalUploaded += data.appendedCount;
966
+ dataset = {
967
+ ...dataset,
968
+ rowCount: data.dataset.rowCount,
969
+ };
970
+ }
971
+ catch (error) {
972
+ const message = error instanceof Error ? error.message : String(error);
973
+ if (!dataset) {
974
+ throw new Error(message);
975
+ }
976
+ throw new Error(`Chunk ${chunkIndex} failed: ${message}\n` +
977
+ `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
978
+ `To retry, remove the first ${totalUploaded} rows from your file and run ` +
979
+ `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
980
+ }
981
+ }
982
+ if (!dataset) {
983
+ throw new Error('Dataset file contains no rows');
984
+ }
985
+ console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`);
986
+ if (dataset.url) {
987
+ console.log(`View dataset: ${formatTerminalLink(dataset.url)}`);
988
+ }
989
+ }
990
+ async function uploadDataset() {
991
+ const projectArg = getArg('--project');
992
+ const fileArg = getArg('--file');
993
+ const name = getArg('--name');
994
+ if (!fileArg) {
995
+ throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]');
996
+ }
997
+ const file = expandHomePath(fileArg);
998
+ const project = await resolveProjectSlug(projectArg);
999
+ const datasetName = name || basename(file);
1000
+ if (extname(file).toLowerCase() === '.jsonl') {
1001
+ await uploadJsonlDatasetInChunks(file, project, datasetName);
1002
+ return;
1003
+ }
1004
+ const { rows, sourceType } = parseDatasetFile(file);
1005
+ const data = await createDatasetFromRows(project, datasetName, sourceType, rows);
790
1006
  console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`);
791
1007
  if (data.dataset.url) {
792
1008
  console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
@@ -830,6 +1046,58 @@ async function downloadDataset() {
830
1046
  writeFileSync(filename, bytes);
831
1047
  console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`);
832
1048
  }
1049
+ const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
1050
+ const APPEND_CHUNK_SIZE_ROWS = 500;
1051
+ async function appendChunk(datasetId, rows) {
1052
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1053
+ method: 'POST',
1054
+ headers: { 'Content-Type': 'application/json' },
1055
+ body: JSON.stringify({ rows }),
1056
+ });
1057
+ if (!response.ok) {
1058
+ throw new Error(`Append failed: ${await response.text()}`);
1059
+ }
1060
+ return parseJsonResponse(response, 'Dataset append');
1061
+ }
1062
+ async function appendJsonlDatasetRowsInChunks(datasetId, file) {
1063
+ let totalAppended = 0;
1064
+ let lastResult = null;
1065
+ let chunkIndex = 0;
1066
+ const chunks = streamJsonlRowChunks(file)[Symbol.asyncIterator]();
1067
+ while (true) {
1068
+ let nextChunk;
1069
+ try {
1070
+ nextChunk = await chunks.next();
1071
+ }
1072
+ catch (error) {
1073
+ const message = error instanceof Error ? error.message : String(error);
1074
+ throw new Error(`Append stopped while reading the next JSONL chunk: ${message}\n` +
1075
+ `${totalAppended} rows from ${chunkIndex} chunk(s) were already appended. ` +
1076
+ `Fix the file, remove the first ${totalAppended} rows, and re-run the command.`);
1077
+ }
1078
+ if (nextChunk.done) {
1079
+ break;
1080
+ }
1081
+ const chunk = nextChunk.value;
1082
+ chunkIndex += 1;
1083
+ console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
1084
+ try {
1085
+ const data = await appendChunk(datasetId, chunk);
1086
+ totalAppended += data.appendedCount;
1087
+ lastResult = data;
1088
+ }
1089
+ catch (error) {
1090
+ const message = error instanceof Error ? error.message : String(error);
1091
+ throw new Error(`Chunk ${chunkIndex} failed: ${message}\n` +
1092
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1093
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`);
1094
+ }
1095
+ }
1096
+ if (!lastResult) {
1097
+ throw new Error('Dataset append file must contain at least one row');
1098
+ }
1099
+ console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1100
+ }
833
1101
  async function appendDatasetRows() {
834
1102
  const projectArg = getArg('--project');
835
1103
  const datasetInput = getDatasetReferenceInput();
@@ -846,20 +1114,63 @@ async function appendDatasetRows() {
846
1114
  datasetId = selected.datasetId;
847
1115
  }
848
1116
  const file = expandHomePath(fileArg);
1117
+ if (extname(file).toLowerCase() === '.jsonl') {
1118
+ await appendJsonlDatasetRowsInChunks(datasetId, file);
1119
+ return;
1120
+ }
1121
+ // Check file size before reading to prevent OOM on large files (ALI-565).
1122
+ // Wrap statSync in try/catch so missing/inaccessible files get friendly
1123
+ // errors instead of raw Node.js ENOENT/EPERM (ALI-554).
1124
+ let fileSizeBytes;
1125
+ try {
1126
+ fileSizeBytes = statSync(file).size;
1127
+ }
1128
+ catch (error) {
1129
+ const maybeError = error;
1130
+ if (maybeError.code === 'ENOENT') {
1131
+ throw new Error(`File not found: ${file}. Check the path and filename, then retry.`);
1132
+ }
1133
+ if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
1134
+ throw new Error(`Cannot read file: ${file}. Grant folder permission to your terminal app and retry.`);
1135
+ }
1136
+ throw new Error(`Failed to access file ${file}: ${maybeError.message}`);
1137
+ }
1138
+ if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
1139
+ const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1);
1140
+ throw new Error(`Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`);
1141
+ }
849
1142
  const { rows } = parseDatasetFile(file);
850
1143
  if (!Array.isArray(rows) || rows.length === 0) {
851
1144
  throw new Error('Dataset append file must contain at least one row');
852
1145
  }
853
- const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
854
- method: 'POST',
855
- headers: { 'Content-Type': 'application/json' },
856
- body: JSON.stringify({ rows }),
857
- });
858
- if (!response.ok) {
859
- throw new Error(`Append failed: ${await response.text()}`);
1146
+ if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1147
+ const data = await appendChunk(datasetId, rows);
1148
+ console.log(`Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
1149
+ return;
1150
+ }
1151
+ // Chunked upload for large row counts (ALI-555: track partial progress)
1152
+ let totalAppended = 0;
1153
+ let lastResult = null;
1154
+ const totalChunks = Math.ceil(rows.length / APPEND_CHUNK_SIZE_ROWS);
1155
+ for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1156
+ const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS);
1157
+ const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1;
1158
+ console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`);
1159
+ try {
1160
+ const data = await appendChunk(datasetId, chunk);
1161
+ totalAppended += data.appendedCount;
1162
+ lastResult = data;
1163
+ }
1164
+ catch (error) {
1165
+ const msg = error instanceof Error ? error.message : String(error);
1166
+ throw new Error(`Chunk ${chunkIndex}/${totalChunks} failed: ${msg}\n` +
1167
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1168
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`);
1169
+ }
1170
+ }
1171
+ if (lastResult) {
1172
+ console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
860
1173
  }
861
- const data = await parseJsonResponse(response, 'Dataset append');
862
- console.log(`Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
863
1174
  }
864
1175
  async function editDatasetRows() {
865
1176
  const projectArg = getArg('--project');
@@ -1073,6 +1384,10 @@ async function main() {
1073
1384
  await linkAppDataset();
1074
1385
  return;
1075
1386
  }
1387
+ if (command === 'apps' && subcommand === 'detail') {
1388
+ await appDetail();
1389
+ return;
1390
+ }
1076
1391
  if (command === 'tasks' && subcommand === 'list') {
1077
1392
  await listTasks();
1078
1393
  return;
@@ -1089,6 +1404,14 @@ async function main() {
1089
1404
  await taskStatus();
1090
1405
  return;
1091
1406
  }
1407
+ if (command === 'tasks' && subcommand === 'pause') {
1408
+ await updateTaskStatus('paused');
1409
+ return;
1410
+ }
1411
+ if (command === 'tasks' && subcommand === 'unpause') {
1412
+ await updateTaskStatus('active');
1413
+ return;
1414
+ }
1092
1415
  if (command === 'datasets' && subcommand === 'upload') {
1093
1416
  await uploadDataset();
1094
1417
  return;