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.
@@ -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
@@ -2,17 +2,19 @@
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
  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 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>]`);
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,44 @@ 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
+ process.exit(1);
704
+ }
705
+ throw cliError;
662
706
  }
663
707
  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}`);
708
+ if (hasArg('--json')) {
709
+ console.log(JSON.stringify({
710
+ taskId: data.task.id,
711
+ datasetId,
712
+ versionId: data.task.versionId,
713
+ versionNum: data.task.versionNum,
714
+ taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
715
+ title: data.task.title,
716
+ status: data.task.status,
717
+ requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
718
+ assignmentsCreated: data.assignmentsCreated,
719
+ ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
720
+ ...(data.warning ? { warning: data.warning } : {}),
721
+ }, null, 2));
722
+ return;
723
+ }
724
+ const baseUrl = getBaseUrl();
725
+ const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`;
726
+ console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
727
+ `\n Task ID: ${data.task.id}` +
728
+ `\n Dataset ID: ${datasetId}` +
729
+ `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
730
+ `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
731
+ `\n Assignments: ${data.assignmentsCreated}` +
732
+ (data.warning ? `\n Warning: ${data.warning}` : '') +
733
+ `\n URL: ${taskUrl}`);
665
734
  }
666
735
  async function assignTask() {
667
736
  const taskId = getArg('--task');
@@ -687,7 +756,25 @@ async function taskStatus() {
687
756
  }
688
757
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`);
689
758
  if (!response.ok) {
690
- throw new Error(`Failed to fetch task status: ${await response.text()}`);
759
+ if (hasArg('--json')) {
760
+ const rawBody = await response.text();
761
+ let errorPayload = { error: rawBody };
762
+ try {
763
+ const parsed = JSON.parse(rawBody);
764
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
765
+ errorPayload = parsed;
766
+ }
767
+ }
768
+ catch {
769
+ // keep raw body as error
770
+ }
771
+ console.log(JSON.stringify({
772
+ ...errorPayload,
773
+ httpStatus: response.status,
774
+ }, null, 2));
775
+ process.exit(1);
776
+ }
777
+ throw new Error(`Failed to fetch task status: ${await extractErrorMessage(response)}`);
691
778
  }
692
779
  const data = await parseJsonResponse(response, 'Task status');
693
780
  if (hasArg('--json')) {
@@ -696,6 +783,68 @@ async function taskStatus() {
696
783
  }
697
784
  printTaskStatusSummary(data);
698
785
  }
786
+ async function updateTaskStatus(targetStatus) {
787
+ const taskId = getArg('--task');
788
+ if (!taskId) {
789
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause';
790
+ throw new Error(`Usage: orizu tasks ${verb} --task <taskId>`);
791
+ }
792
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`, {
793
+ method: 'PATCH',
794
+ headers: { 'Content-Type': 'application/json' },
795
+ body: JSON.stringify({ status: targetStatus }),
796
+ });
797
+ if (!response.ok) {
798
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause';
799
+ throw new Error(`Failed to ${verb} task: ${await response.text()}`);
800
+ }
801
+ const data = await parseJsonResponse(response, 'Task status update');
802
+ const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused';
803
+ console.log(`${action} task ${data.task.id} [${data.task.status}]`);
804
+ }
805
+ async function appDetail() {
806
+ const appId = getArg('--app');
807
+ const project = getArg('--project');
808
+ if (!appId) {
809
+ throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]');
810
+ }
811
+ const projectSlug = project || await resolveProjectSlug(null);
812
+ const apps = await fetchApps(projectSlug);
813
+ const app = apps.find(a => a.id === appId);
814
+ if (!app) {
815
+ throw new Error(`App '${appId}' not found in project '${projectSlug}'`);
816
+ }
817
+ // Re-fetch full detail from the list (it already returns full data)
818
+ const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`);
819
+ if (!detailResponse.ok) {
820
+ throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`);
821
+ }
822
+ const detailData = await parseJsonResponse(detailResponse, 'App detail');
823
+ const detail = detailData.apps.find(a => a.id === appId);
824
+ if (!detail) {
825
+ throw new Error(`App '${appId}' not found`);
826
+ }
827
+ if (hasArg('--json')) {
828
+ console.log(JSON.stringify(detail, null, 2));
829
+ return;
830
+ }
831
+ console.log(`App: ${detail.name} (${detail.id})`);
832
+ console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`);
833
+ if (detail.currentVersion) {
834
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`);
835
+ console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`);
836
+ console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`);
837
+ }
838
+ else {
839
+ console.log(` Current version: none`);
840
+ }
841
+ console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`);
842
+ if (detail.createdByEmail) {
843
+ console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`);
844
+ }
845
+ console.log(` Created: ${detail.createdAt}`);
846
+ console.log(` Updated: ${detail.updatedAt}`);
847
+ }
699
848
  async function listTeamMembers() {
700
849
  const teamSlug = await resolveTeamSlug(getArg('--team'));
701
850
  printTeamMembers(await fetchTeamMembers(teamSlug));
@@ -830,6 +979,19 @@ async function downloadDataset() {
830
979
  writeFileSync(filename, bytes);
831
980
  console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`);
832
981
  }
982
+ const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
983
+ const APPEND_CHUNK_SIZE_ROWS = 500;
984
+ async function appendChunk(datasetId, rows) {
985
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
986
+ method: 'POST',
987
+ headers: { 'Content-Type': 'application/json' },
988
+ body: JSON.stringify({ rows }),
989
+ });
990
+ if (!response.ok) {
991
+ throw new Error(`Append failed: ${await response.text()}`);
992
+ }
993
+ return parseJsonResponse(response, 'Dataset append');
994
+ }
833
995
  async function appendDatasetRows() {
834
996
  const projectArg = getArg('--project');
835
997
  const datasetInput = getDatasetReferenceInput();
@@ -846,43 +1008,98 @@ async function appendDatasetRows() {
846
1008
  datasetId = selected.datasetId;
847
1009
  }
848
1010
  const file = expandHomePath(fileArg);
1011
+ // Parse the file first — parseDatasetFile provides user-friendly errors for
1012
+ // ENOENT, EPERM, and EACCES. We check the file size afterward using the
1013
+ // already-read content length to avoid a raw statSync ENOENT (ALI-554).
849
1014
  const { rows } = parseDatasetFile(file);
850
1015
  if (!Array.isArray(rows) || rows.length === 0) {
851
1016
  throw new Error('Dataset append file must contain at least one row');
852
1017
  }
1018
+ const fileSizeBytes = statSync(file).size;
1019
+ if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
1020
+ const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1);
1021
+ throw new Error(`Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`);
1022
+ }
1023
+ if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1024
+ const data = await appendChunk(datasetId, rows);
1025
+ console.log(`Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
1026
+ return;
1027
+ }
1028
+ // Chunked upload for large row counts (ALI-555: track partial progress)
1029
+ let totalAppended = 0;
1030
+ let lastResult = null;
1031
+ const totalChunks = Math.ceil(rows.length / APPEND_CHUNK_SIZE_ROWS);
1032
+ for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1033
+ const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS);
1034
+ const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1;
1035
+ console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`);
1036
+ try {
1037
+ const data = await appendChunk(datasetId, chunk);
1038
+ totalAppended += data.appendedCount;
1039
+ lastResult = data;
1040
+ }
1041
+ catch (error) {
1042
+ const msg = error instanceof Error ? error.message : String(error);
1043
+ throw new Error(`Chunk ${chunkIndex}/${totalChunks} failed: ${msg}\n` +
1044
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1045
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`);
1046
+ }
1047
+ }
1048
+ if (lastResult) {
1049
+ console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1050
+ }
1051
+ }
1052
+ async function editDatasetRows() {
1053
+ const projectArg = getArg('--project');
1054
+ const datasetInput = getDatasetReferenceInput();
1055
+ const fileArg = getArg('--file');
1056
+ if (!fileArg) {
1057
+ throw new Error('Usage: orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>');
1058
+ }
1059
+ let datasetId;
1060
+ if (datasetInput) {
1061
+ datasetId = parseDatasetReference(datasetInput).datasetId;
1062
+ }
1063
+ else {
1064
+ const selected = await selectDatasetInteractively(projectArg);
1065
+ datasetId = selected.datasetId;
1066
+ }
1067
+ const file = expandHomePath(fileArg);
1068
+ const { rows } = parseDatasetFile(file);
1069
+ if (!Array.isArray(rows) || rows.length === 0) {
1070
+ throw new Error('Dataset edit file must contain at least one row');
1071
+ }
1072
+ const normalizedRows = rows.map((row, index) => {
1073
+ if (typeof row !== 'object' || row === null || Array.isArray(row)) {
1074
+ throw new Error(`Dataset edit file rows[${index}] must be an object`);
1075
+ }
1076
+ const rowRecord = row;
1077
+ const rowId = typeof rowRecord.id === 'string' ? rowRecord.id.trim() : '';
1078
+ if (!rowId) {
1079
+ throw new Error(`Dataset edit file rows[${index}] must include a non-empty string id`);
1080
+ }
1081
+ return {
1082
+ ...rowRecord,
1083
+ id: rowId,
1084
+ };
1085
+ });
853
1086
  const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
854
- method: 'POST',
1087
+ method: 'PATCH',
855
1088
  headers: { 'Content-Type': 'application/json' },
856
- body: JSON.stringify({ rows }),
1089
+ body: JSON.stringify({ rows: normalizedRows }),
857
1090
  });
858
1091
  if (!response.ok) {
859
- throw new Error(`Append failed: ${await response.text()}`);
860
- }
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
- }
864
- function parseCommaSeparatedIntegers(value) {
865
- if (!value) {
866
- return [];
1092
+ throw new Error(`Edit rows failed: ${await response.text()}`);
867
1093
  }
868
- const rawItems = value
869
- .split(',')
870
- .map(item => item.trim())
871
- .filter(Boolean);
872
- const parsed = rawItems.map(item => Number(item));
873
- const invalid = parsed.some(item => !Number.isInteger(item) || item < 0);
874
- if (invalid) {
875
- throw new Error('row-indices must be comma-separated non-negative integers');
876
- }
877
- return parsed;
1094
+ const data = await parseJsonResponse(response, 'Dataset edit rows');
1095
+ console.log(`Updated ${data.updatedCount} rows in dataset ${data.dataset.name} (${data.dataset.id}). Current row count: ${data.dataset.rowCount}`);
878
1096
  }
879
1097
  async function deleteDatasetRows() {
880
1098
  const projectArg = getArg('--project');
881
1099
  const datasetInput = getDatasetReferenceInput();
882
1100
  const rowIds = parseCommaSeparated(getArg('--row-ids'));
883
- const rowIndices = parseCommaSeparatedIntegers(getArg('--row-indices'));
884
- if (rowIds.length === 0 && rowIndices.length === 0) {
885
- throw new Error('Usage: orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--row-ids <id1,id2>] [--row-indices <n1,n2>]');
1101
+ if (rowIds.length === 0) {
1102
+ throw new Error('Usage: orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>');
886
1103
  }
887
1104
  let datasetId;
888
1105
  if (datasetInput) {
@@ -897,7 +1114,6 @@ async function deleteDatasetRows() {
897
1114
  headers: { 'Content-Type': 'application/json' },
898
1115
  body: JSON.stringify({
899
1116
  rowIds,
900
- rowIndices,
901
1117
  }),
902
1118
  });
903
1119
  if (!response.ok) {
@@ -906,6 +1122,52 @@ async function deleteDatasetRows() {
906
1122
  const data = await parseJsonResponse(response, 'Dataset delete rows');
907
1123
  console.log(`Deleted ${data.deletedCount} rows from dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
908
1124
  }
1125
+ async function lockDataset() {
1126
+ const projectArg = getArg('--project');
1127
+ const datasetInput = getDatasetReferenceInput();
1128
+ const reason = getArg('--reason');
1129
+ let datasetId;
1130
+ if (datasetInput) {
1131
+ datasetId = parseDatasetReference(datasetInput).datasetId;
1132
+ }
1133
+ else {
1134
+ const selected = await selectDatasetInteractively(projectArg);
1135
+ datasetId = selected.datasetId;
1136
+ }
1137
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/lock`, {
1138
+ method: 'POST',
1139
+ headers: { 'Content-Type': 'application/json' },
1140
+ body: JSON.stringify(reason ? { reason } : {}),
1141
+ });
1142
+ if (!response.ok) {
1143
+ throw new Error(`Lock failed: ${await response.text()}`);
1144
+ }
1145
+ const data = await parseJsonResponse(response, 'Dataset lock');
1146
+ console.log(`Locked dataset ${data.dataset.name} (${data.dataset.id}) at ${data.dataset.lockedAt}. Row count: ${data.dataset.rowCount}`);
1147
+ }
1148
+ async function cloneDataset() {
1149
+ const projectArg = getArg('--project');
1150
+ const datasetInput = getDatasetReferenceInput();
1151
+ const name = getArg('--name');
1152
+ let datasetId;
1153
+ if (datasetInput) {
1154
+ datasetId = parseDatasetReference(datasetInput).datasetId;
1155
+ }
1156
+ else {
1157
+ const selected = await selectDatasetInteractively(projectArg);
1158
+ datasetId = selected.datasetId;
1159
+ }
1160
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/clone`, {
1161
+ method: 'POST',
1162
+ headers: { 'Content-Type': 'application/json' },
1163
+ body: JSON.stringify(name ? { name } : {}),
1164
+ });
1165
+ if (!response.ok) {
1166
+ throw new Error(`Clone failed: ${await response.text()}`);
1167
+ }
1168
+ const data = await parseJsonResponse(response, 'Dataset clone');
1169
+ console.log(`Cloned dataset ${data.dataset.parentDatasetId} -> ${data.dataset.name} (${data.dataset.id}). Row count: ${data.dataset.rowCount}`);
1170
+ }
909
1171
  async function downloadAnnotations() {
910
1172
  let taskId = getArg('--task');
911
1173
  const format = (getArg('--format') || 'jsonl');
@@ -999,6 +1261,10 @@ async function main() {
999
1261
  await linkAppDataset();
1000
1262
  return;
1001
1263
  }
1264
+ if (command === 'apps' && subcommand === 'detail') {
1265
+ await appDetail();
1266
+ return;
1267
+ }
1002
1268
  if (command === 'tasks' && subcommand === 'list') {
1003
1269
  await listTasks();
1004
1270
  return;
@@ -1015,6 +1281,14 @@ async function main() {
1015
1281
  await taskStatus();
1016
1282
  return;
1017
1283
  }
1284
+ if (command === 'tasks' && subcommand === 'pause') {
1285
+ await updateTaskStatus('paused');
1286
+ return;
1287
+ }
1288
+ if (command === 'tasks' && subcommand === 'unpause') {
1289
+ await updateTaskStatus('active');
1290
+ return;
1291
+ }
1018
1292
  if (command === 'datasets' && subcommand === 'upload') {
1019
1293
  await uploadDataset();
1020
1294
  return;
@@ -1027,10 +1301,22 @@ async function main() {
1027
1301
  await appendDatasetRows();
1028
1302
  return;
1029
1303
  }
1304
+ if (command === 'datasets' && subcommand === 'edit-rows') {
1305
+ await editDatasetRows();
1306
+ return;
1307
+ }
1030
1308
  if (command === 'datasets' && subcommand === 'delete-rows') {
1031
1309
  await deleteDatasetRows();
1032
1310
  return;
1033
1311
  }
1312
+ if (command === 'datasets' && subcommand === 'lock') {
1313
+ await lockDataset();
1314
+ return;
1315
+ }
1316
+ if (command === 'datasets' && subcommand === 'clone') {
1317
+ await cloneDataset();
1318
+ return;
1319
+ }
1034
1320
  if (command === 'tasks' && subcommand === 'export') {
1035
1321
  await downloadAnnotations();
1036
1322
  return;
@@ -0,0 +1,60 @@
1
+ function createCliError(message, httpStatus, structuredPayload) {
2
+ const error = new Error(message);
3
+ error.httpStatus = httpStatus;
4
+ error.structuredPayload = structuredPayload;
5
+ return error;
6
+ }
7
+ export async function formatTaskCreateError(response) {
8
+ const contentType = response.headers.get('content-type') || '';
9
+ const rawBody = await response.text();
10
+ const httpStatus = response.status;
11
+ let parsedPayload;
12
+ if (!contentType.includes('application/json')) {
13
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus);
14
+ }
15
+ try {
16
+ const payload = JSON.parse(rawBody);
17
+ parsedPayload = payload;
18
+ if (Array.isArray(payload.invalidAssigneeErrors) &&
19
+ payload.invalidAssigneeErrors.length > 0) {
20
+ const details = payload.invalidAssigneeErrors
21
+ .filter(item => typeof item.assignee === 'string' &&
22
+ item.assignee.length > 0 &&
23
+ typeof item.reason === 'string' &&
24
+ item.reason.length > 0)
25
+ .map(item => ` - ${item.assignee}: ${item.reason}`)
26
+ .join('\n');
27
+ if (details) {
28
+ return createCliError(`Failed to create task: ${payload.error || 'Invalid assignees'}\n${details}`, httpStatus, parsedPayload);
29
+ }
30
+ }
31
+ if (Array.isArray(payload.missingRequiredFields) &&
32
+ payload.missingRequiredFields.length > 0) {
33
+ const lines = [
34
+ `Failed to create task: ${payload.error || 'Dataset incompatible'}`,
35
+ ];
36
+ if (payload.versionNum !== undefined) {
37
+ lines.push(` Version: v${payload.versionNum} (${payload.versionId || 'unknown'})`);
38
+ }
39
+ lines.push(` Missing required fields: ${payload.missingRequiredFields.join(', ')}`);
40
+ if (typeof payload.incompatibleRowCount === 'number') {
41
+ lines.push(` Incompatible rows: ${payload.incompatibleRowCount}`);
42
+ }
43
+ if (payload.missingRequiredFieldCounts &&
44
+ Object.keys(payload.missingRequiredFieldCounts).length > 0) {
45
+ const fieldCounts = Object.entries(payload.missingRequiredFieldCounts)
46
+ .map(([field, count]) => ` ${field}: ${count} rows`)
47
+ .join('\n');
48
+ lines.push(` Missing field counts:\n${fieldCounts}`);
49
+ }
50
+ return createCliError(lines.join('\n'), httpStatus, parsedPayload);
51
+ }
52
+ if (typeof payload.error === 'string' && payload.error.length > 0) {
53
+ return createCliError(`Failed to create task: ${payload.error}`, httpStatus, parsedPayload);
54
+ }
55
+ }
56
+ catch {
57
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus);
58
+ }
59
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus, parsedPayload);
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {