orizu 0.0.9 → 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 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,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,20 +1008,46 @@ 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
  }
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()}`);
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}`);
860
1050
  }
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
1051
  }
864
1052
  async function editDatasetRows() {
865
1053
  const projectArg = getArg('--project');
@@ -1073,6 +1261,10 @@ async function main() {
1073
1261
  await linkAppDataset();
1074
1262
  return;
1075
1263
  }
1264
+ if (command === 'apps' && subcommand === 'detail') {
1265
+ await appDetail();
1266
+ return;
1267
+ }
1076
1268
  if (command === 'tasks' && subcommand === 'list') {
1077
1269
  await listTasks();
1078
1270
  return;
@@ -1089,6 +1281,14 @@ async function main() {
1089
1281
  await taskStatus();
1090
1282
  return;
1091
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
+ }
1092
1292
  if (command === 'datasets' && subcommand === 'upload') {
1093
1293
  await uploadDataset();
1094
1294
  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.9",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,7 +82,15 @@ export function loadCredentialsConfig(): StoredCredentialsV2 | null {
82
82
  }
83
83
 
84
84
  const raw = readFileSync(path, 'utf-8')
85
- const parsed = JSON.parse(raw) as unknown
85
+
86
+ let parsed: unknown
87
+ try {
88
+ parsed = JSON.parse(raw)
89
+ } catch {
90
+ console.warn('Warning: credentials file contains invalid JSON — please re-login with `orizu login`')
91
+ return null
92
+ }
93
+
86
94
  if (isStoredCredentialsV2(parsed)) {
87
95
  return parsed
88
96
  }
@@ -91,7 +99,8 @@ export function loadCredentialsConfig(): StoredCredentialsV2 | null {
91
99
  return migrateToV2(parsed)
92
100
  }
93
101
 
94
- throw new Error('Invalid credentials file format.')
102
+ console.warn('Warning: credentials file has unrecognized format — please re-login with `orizu login`')
103
+ return null
95
104
  }
96
105
 
97
106
  export function getServerCredentials(baseUrl: string): ServerCredentials | null {
@@ -0,0 +1,27 @@
1
+ interface ErrorResponsePayload {
2
+ error?: string
3
+ }
4
+
5
+ export async function extractErrorMessage(response: Response): Promise<string> {
6
+ const contentType = response.headers.get('content-type') || ''
7
+ const rawBody = await response.text()
8
+
9
+ if (contentType.includes('application/json')) {
10
+ try {
11
+ const payload = JSON.parse(rawBody) as ErrorResponsePayload | null
12
+ if (
13
+ payload &&
14
+ typeof payload === 'object' &&
15
+ !Array.isArray(payload) &&
16
+ typeof payload.error === 'string' &&
17
+ payload.error.length > 0
18
+ ) {
19
+ return payload.error
20
+ }
21
+ } catch {
22
+ // Fall through to raw body
23
+ }
24
+ }
25
+
26
+ return rawBody
27
+ }
package/src/index.ts CHANGED
@@ -2,15 +2,17 @@
2
2
  import { createHash, randomBytes } from 'crypto'
3
3
  import { basename } from 'path'
4
4
  import { createServer } from 'http'
5
- import { readFileSync, writeFileSync } from 'fs'
5
+ import { readFileSync, statSync, writeFileSync } from 'fs'
6
6
  import { spawn } from 'child_process'
7
7
  import { createInterface } from 'readline/promises'
8
8
  import { stdin as input, stdout as output } from 'process'
9
9
  import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js'
10
10
  import { parseDatasetFile } from './file-parser.js'
11
11
  import { parseDatasetReference } from './dataset-download.js'
12
+ import { extractErrorMessage } from './error-response.js'
12
13
  import { parseGlobalFlags } from './global-flags.js'
13
14
  import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js'
15
+ import { formatTaskCreateError } from './task-create-error.js'
14
16
  import { LoginResponse } from './types.js'
15
17
 
16
18
  interface Team {
@@ -106,7 +108,7 @@ interface TaskStatusPayload {
106
108
  }
107
109
 
108
110
  function printUsage() {
109
- console.log(`orizu global options:\n\n --local Use http://localhost:3000\n --server <url> Use a specific server origin (for example: https://preview.example.com)\n\norizu commands:\n\n orizu login\n orizu logout\n orizu whoami\n orizu teams list\n orizu teams create [--name <name>]\n orizu teams members list [--team <teamSlug>]\n orizu teams members add --email <email> [--team <teamSlug>]\n orizu teams members remove --email <email> [--team <teamSlug>]\n orizu teams members role --team <teamSlug> --email <email> --role <admin|member>\n orizu projects list [--team <teamSlug>]\n orizu projects create --name <name> [--team <teamSlug>]\n orizu apps list [--project <team/project>]\n orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userId1,userId2> [--instructions <text>] [--labels-per-item <n>]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets 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>]`)
111
+ console.log(`orizu global options:\n\n --local Use http://localhost:3000\n --server <url> Use a specific server origin (for example: https://preview.example.com)\n\norizu commands:\n\n orizu login\n orizu logout\n orizu whoami\n orizu teams list\n orizu teams create [--name <name>]\n orizu teams members list [--team <teamSlug>]\n orizu teams members add --email <email> [--team <teamSlug>]\n orizu teams members remove --email <email> [--team <teamSlug>]\n orizu teams members role --team <teamSlug> --email <email> --role <admin|member>\n orizu projects list [--team <teamSlug>]\n orizu projects create --name <name> [--team <teamSlug>]\n orizu apps list [--project <team/project>]\n orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]\n orizu apps detail --app <appId> [--project <team/project>] [--json]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu tasks pause --task <taskId>\n orizu tasks unpause --task <taskId>\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>\n orizu datasets lock [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--reason <text>]\n orizu datasets clone [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
110
112
  }
111
113
 
112
114
  let cliArgs = process.argv.slice(2)
@@ -198,7 +200,6 @@ async function parseJsonResponse<T>(response: Response, context: string): Promis
198
200
  )
199
201
  }
200
202
  }
201
-
202
203
  async function promptSelect<T>(
203
204
  title: string,
204
205
  items: T[],
@@ -559,18 +560,26 @@ function printTeamMembers(members: TeamMember[]) {
559
560
 
560
561
  const rows = members.map(member => ({
561
562
  id: member.id,
563
+ userId: member.user_id || '-',
562
564
  email: member.email || '-',
563
565
  role: member.role || '-',
564
566
  }))
565
567
 
566
568
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
569
+ const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length))
567
570
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
568
571
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
569
572
 
570
- console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`)
571
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`)
573
+ console.log(
574
+ `${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`
575
+ )
576
+ console.log(
577
+ `${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`
578
+ )
572
579
  rows.forEach(row => {
573
- console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`)
580
+ console.log(
581
+ `${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`
582
+ )
574
583
  })
575
584
  }
576
585
 
@@ -593,15 +602,34 @@ function printTaskStatusSummary(data: TaskStatusPayload) {
593
602
  }
594
603
  }
595
604
 
605
+ const DEFAULT_AUTH_CALLBACK_PORT = 43123
606
+
607
+ function resolveAuthCallbackPort(): number {
608
+ const envPort = process.env.ORIZU_AUTH_PORT
609
+ if (!envPort) {
610
+ return DEFAULT_AUTH_CALLBACK_PORT
611
+ }
612
+
613
+ const parsed = parseInt(envPort, 10)
614
+ if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) {
615
+ throw new Error(
616
+ `Invalid ORIZU_AUTH_PORT: '${envPort}'. Must be a number between 1024 and 65535.`
617
+ )
618
+ }
619
+
620
+ return parsed
621
+ }
622
+
596
623
  async function login() {
597
624
  const baseUrl = resolveLoginBaseUrl()
598
625
  const codeVerifier = createCodeVerifier()
599
626
  const codeChallenge = createCodeChallenge(codeVerifier)
627
+ const callbackPort = resolveAuthCallbackPort()
600
628
 
601
629
  const callbackCode = await new Promise<string>((resolve, reject) => {
602
630
  const server = createServer((request, response) => {
603
631
  try {
604
- const url = new URL(request.url || '/', 'http://127.0.0.1:43123')
632
+ const url = new URL(request.url || '/', `http://127.0.0.1:${callbackPort}`)
605
633
  const code = url.searchParams.get('code')
606
634
 
607
635
  if (!code) {
@@ -622,11 +650,19 @@ async function login() {
622
650
  }
623
651
  })
624
652
 
625
- server.on('error', reject)
653
+ server.on('error', (error: NodeJS.ErrnoException) => {
654
+ if (error.code === 'EADDRINUSE') {
655
+ reject(new Error(
656
+ `Port ${callbackPort} is already in use. Set ORIZU_AUTH_PORT to a different port (1024–65535) and retry.`
657
+ ))
658
+ } else {
659
+ reject(error)
660
+ }
661
+ })
626
662
 
627
- server.listen(43123, '127.0.0.1', async () => {
663
+ server.listen(callbackPort, '127.0.0.1', async () => {
628
664
  try {
629
- const redirectUri = 'http://127.0.0.1:43123/callback'
665
+ const redirectUri = `http://127.0.0.1:${callbackPort}/callback`
630
666
  const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
631
667
  method: 'POST',
632
668
  headers: { 'Content-Type': 'application/json' },
@@ -921,12 +957,18 @@ async function linkAppDataset() {
921
957
  const project = getArg('--project')
922
958
  let appId = getArg('--app')
923
959
  const versionArg = getArg('--version')
924
- const versionNum = versionArg ? Number(versionArg) : undefined
960
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
961
+ const versionNum =
962
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : undefined
925
963
 
926
964
  if (!datasetId) {
927
965
  throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]')
928
966
  }
929
967
 
968
+ if (versionArg && versionNum === undefined) {
969
+ throw new Error('--version must be a positive integer')
970
+ }
971
+
930
972
  if (!appId) {
931
973
  const selected = await selectAppIdInteractively(project)
932
974
  appId = selected.appId
@@ -972,12 +1014,20 @@ async function createTask() {
972
1014
  const appId = getArg('--app')
973
1015
  const title = getArg('--title')
974
1016
  const assignees = parseCommaSeparated(getArg('--assignees'))
1017
+ const versionArg = getArg('--version')
975
1018
  const instructions = getArg('--instructions')
976
1019
  const labelsPerItemArg = getArg('--labels-per-item')
977
1020
  const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
1021
+ const parsedVersionNum = versionArg ? Number(versionArg) : Number.NaN
1022
+ const versionNum =
1023
+ Number.isInteger(parsedVersionNum) && parsedVersionNum > 0 ? parsedVersionNum : null
978
1024
 
979
1025
  if (!projectSlug || !datasetId || !appId || !title || assignees.length === 0) {
980
- throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userId1,userId2> [--instructions <text>] [--labels-per-item <n>]')
1026
+ throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]')
1027
+ }
1028
+
1029
+ if (versionArg && versionNum === null) {
1030
+ throw new Error('--version must be a positive integer')
981
1031
  }
982
1032
 
983
1033
  const response = await authedFetch('/api/cli/tasks', {
@@ -987,6 +1037,7 @@ async function createTask() {
987
1037
  projectSlug,
988
1038
  datasetId,
989
1039
  appId,
1040
+ versionNum,
990
1041
  title,
991
1042
  memberIds: assignees,
992
1043
  instructions,
@@ -995,15 +1046,59 @@ async function createTask() {
995
1046
  })
996
1047
 
997
1048
  if (!response.ok) {
998
- throw new Error(`Failed to create task: ${await response.text()}`)
1049
+ const cliError = await formatTaskCreateError(response)
1050
+ if (hasArg('--json')) {
1051
+ const payload = cliError.structuredPayload ?? { error: cliError.message }
1052
+ console.log(JSON.stringify({
1053
+ ...payload,
1054
+ httpStatus: cliError.httpStatus,
1055
+ }, null, 2))
1056
+ }
1057
+ throw cliError
999
1058
  }
1000
1059
 
1001
1060
  const data = await parseJsonResponse<{
1002
- task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
1061
+ task: {
1062
+ id: string
1063
+ title: string
1064
+ status: string
1065
+ requiredAssignmentsPerRow: number
1066
+ versionId: string
1067
+ versionNum: number
1068
+ }
1003
1069
  assignmentsCreated: number
1070
+ assignmentShortfall?: number
1071
+ warning?: string
1004
1072
  }>(response, 'Task create')
1073
+
1074
+ if (hasArg('--json')) {
1075
+ console.log(JSON.stringify({
1076
+ taskId: data.task.id,
1077
+ datasetId,
1078
+ versionId: data.task.versionId,
1079
+ versionNum: data.task.versionNum,
1080
+ taskUrl: `${getBaseUrl()}/d/${projectSlug}/tasks/${data.task.id}`,
1081
+ title: data.task.title,
1082
+ status: data.task.status,
1083
+ requiredAssignmentsPerRow: data.task.requiredAssignmentsPerRow,
1084
+ assignmentsCreated: data.assignmentsCreated,
1085
+ ...(data.assignmentShortfall !== undefined ? { assignmentShortfall: data.assignmentShortfall } : {}),
1086
+ ...(data.warning ? { warning: data.warning } : {}),
1087
+ }, null, 2))
1088
+ return
1089
+ }
1090
+
1091
+ const baseUrl = getBaseUrl()
1092
+ const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`
1005
1093
  console.log(
1006
- `Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`
1094
+ `Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
1095
+ `\n Task ID: ${data.task.id}` +
1096
+ `\n Dataset ID: ${datasetId}` +
1097
+ `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
1098
+ `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
1099
+ `\n Assignments: ${data.assignmentsCreated}` +
1100
+ (data.warning ? `\n Warning: ${data.warning}` : '') +
1101
+ `\n URL: ${taskUrl}`
1007
1102
  )
1008
1103
  }
1009
1104
 
@@ -1037,7 +1132,31 @@ async function taskStatus() {
1037
1132
 
1038
1133
  const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`)
1039
1134
  if (!response.ok) {
1040
- throw new Error(`Failed to fetch task status: ${await response.text()}`)
1135
+ const rawBody = await response.text()
1136
+ if (hasArg('--json')) {
1137
+ let errorPayload: Record<string, unknown> = { error: rawBody }
1138
+ try {
1139
+ const parsed = JSON.parse(rawBody)
1140
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1141
+ errorPayload = parsed as Record<string, unknown>
1142
+ }
1143
+ } catch {
1144
+ // keep raw body as error
1145
+ }
1146
+ console.log(JSON.stringify({
1147
+ ...errorPayload,
1148
+ httpStatus: response.status,
1149
+ }, null, 2))
1150
+ }
1151
+
1152
+ let errorMsg = rawBody
1153
+ try {
1154
+ const parsed = JSON.parse(rawBody)
1155
+ if (parsed && typeof parsed === 'object' && typeof parsed.error === 'string') {
1156
+ errorMsg = parsed.error
1157
+ }
1158
+ } catch { /* use rawBody as-is */ }
1159
+ throw new Error(`Failed to fetch task status: ${errorMsg}`)
1041
1160
  }
1042
1161
 
1043
1162
  const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
@@ -1049,6 +1168,97 @@ async function taskStatus() {
1049
1168
  printTaskStatusSummary(data)
1050
1169
  }
1051
1170
 
1171
+ async function updateTaskStatus(targetStatus: 'paused' | 'active') {
1172
+ const taskId = getArg('--task')
1173
+ if (!taskId) {
1174
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1175
+ throw new Error(`Usage: orizu tasks ${verb} --task <taskId>`)
1176
+ }
1177
+
1178
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`, {
1179
+ method: 'PATCH',
1180
+ headers: { 'Content-Type': 'application/json' },
1181
+ body: JSON.stringify({ status: targetStatus }),
1182
+ })
1183
+
1184
+ if (!response.ok) {
1185
+ const verb = targetStatus === 'paused' ? 'pause' : 'unpause'
1186
+ throw new Error(`Failed to ${verb} task: ${await response.text()}`)
1187
+ }
1188
+
1189
+ const data = await parseJsonResponse<{ task: { id: string; status: string } }>(response, 'Task status update')
1190
+ const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused'
1191
+ console.log(`${action} task ${data.task.id} [${data.task.status}]`)
1192
+ }
1193
+
1194
+ interface AppDetailPayload {
1195
+ id: string
1196
+ name: string
1197
+ currentVersionNum: number | null
1198
+ currentVersion: {
1199
+ versionId: string
1200
+ versionNum: number
1201
+ inputJsonSchema: unknown
1202
+ outputJsonSchema: unknown
1203
+ } | null
1204
+ createdAt: string
1205
+ updatedAt: string
1206
+ projectId: string | null
1207
+ compatibleDatasetsCount: number
1208
+ totalDatasetsCount: number
1209
+ createdByName: string | null
1210
+ createdByEmail: string | null
1211
+ teamSlug: string
1212
+ teamName: string
1213
+ projectSlug: string
1214
+ projectName: string
1215
+ }
1216
+
1217
+ async function appDetail() {
1218
+ const appId = getArg('--app')
1219
+ const project = getArg('--project')
1220
+
1221
+ if (!appId) {
1222
+ throw new Error('Usage: orizu apps detail --app <appId> [--project <team/project>] [--json]')
1223
+ }
1224
+
1225
+ const projectSlug = project || await resolveProjectSlug(null)
1226
+
1227
+ // Single fetch — the apps endpoint already returns full detail (ALI-544)
1228
+ const detailResponse = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(projectSlug)}`)
1229
+ if (!detailResponse.ok) {
1230
+ throw new Error(`Failed to fetch app detail: ${await detailResponse.text()}`)
1231
+ }
1232
+
1233
+ const detailData = await parseJsonResponse<{ apps: AppDetailPayload[] }>(detailResponse, 'App detail')
1234
+ const detail = detailData.apps.find(a => a.id === appId)
1235
+
1236
+ if (!detail) {
1237
+ throw new Error(`App '${appId}' not found in project '${projectSlug}'`)
1238
+ }
1239
+
1240
+ if (hasArg('--json')) {
1241
+ console.log(JSON.stringify(detail, null, 2))
1242
+ return
1243
+ }
1244
+
1245
+ console.log(`App: ${detail.name} (${detail.id})`)
1246
+ console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`)
1247
+ if (detail.currentVersion) {
1248
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`)
1249
+ console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`)
1250
+ console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`)
1251
+ } else {
1252
+ console.log(` Current version: none`)
1253
+ }
1254
+ console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`)
1255
+ if (detail.createdByEmail) {
1256
+ console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`)
1257
+ }
1258
+ console.log(` Created: ${detail.createdAt}`)
1259
+ console.log(` Updated: ${detail.updatedAt}`)
1260
+ }
1261
+
1052
1262
  async function listTeamMembers() {
1053
1263
  const teamSlug = await resolveTeamSlug(getArg('--team'))
1054
1264
 
@@ -1222,6 +1432,29 @@ async function downloadDataset() {
1222
1432
  console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`)
1223
1433
  }
1224
1434
 
1435
+ const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024 // 50 MB
1436
+ const APPEND_CHUNK_SIZE_ROWS = 500
1437
+
1438
+ async function appendChunk(
1439
+ datasetId: string,
1440
+ rows: Array<Record<string, unknown>>
1441
+ ): Promise<{ dataset: { id: string; name: string; rowCount: number }; appendedCount: number }> {
1442
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
1443
+ method: 'POST',
1444
+ headers: { 'Content-Type': 'application/json' },
1445
+ body: JSON.stringify({ rows }),
1446
+ })
1447
+
1448
+ if (!response.ok) {
1449
+ throw new Error(`Append failed: ${await response.text()}`)
1450
+ }
1451
+
1452
+ return parseJsonResponse<{
1453
+ dataset: { id: string; name: string; rowCount: number }
1454
+ appendedCount: number
1455
+ }>(response, 'Dataset append')
1456
+ }
1457
+
1225
1458
  async function appendDatasetRows() {
1226
1459
  const projectArg = getArg('--project')
1227
1460
  const datasetInput = getDatasetReferenceInput()
@@ -1240,29 +1473,76 @@ async function appendDatasetRows() {
1240
1473
  }
1241
1474
 
1242
1475
  const file = expandHomePath(fileArg)
1476
+
1477
+ // Check file size before reading to prevent OOM on large files (ALI-565).
1478
+ // Wrap statSync in try/catch so missing/inaccessible files get friendly
1479
+ // errors instead of raw Node.js ENOENT/EPERM (ALI-554).
1480
+ let fileSizeBytes: number
1481
+ try {
1482
+ fileSizeBytes = statSync(file).size
1483
+ } catch (error) {
1484
+ const maybeError = error as NodeJS.ErrnoException
1485
+ if (maybeError.code === 'ENOENT') {
1486
+ throw new Error(
1487
+ `File not found: ${file}. Check the path and filename, then retry.`
1488
+ )
1489
+ }
1490
+ if (maybeError.code === 'EPERM' || maybeError.code === 'EACCES') {
1491
+ throw new Error(
1492
+ `Cannot read file: ${file}. Grant folder permission to your terminal app and retry.`
1493
+ )
1494
+ }
1495
+ throw new Error(`Failed to access file ${file}: ${maybeError.message}`)
1496
+ }
1497
+ if (fileSizeBytes > MAX_INPUT_FILE_SIZE_BYTES) {
1498
+ const sizeMb = (fileSizeBytes / (1024 * 1024)).toFixed(1)
1499
+ throw new Error(
1500
+ `Input file is ${sizeMb} MB, which exceeds the 50 MB limit. Split the file into smaller parts and append each separately.`
1501
+ )
1502
+ }
1503
+
1243
1504
  const { rows } = parseDatasetFile(file)
1244
1505
  if (!Array.isArray(rows) || rows.length === 0) {
1245
1506
  throw new Error('Dataset append file must contain at least one row')
1246
1507
  }
1247
1508
 
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()}`)
1509
+ if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1510
+ const data = await appendChunk(datasetId, rows)
1511
+ console.log(
1512
+ `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1513
+ )
1514
+ return
1256
1515
  }
1257
1516
 
1258
- const data = await parseJsonResponse<{
1259
- dataset: { id: string; name: string; rowCount: number }
1260
- appendedCount: number
1261
- }>(response, 'Dataset append')
1517
+ // Chunked upload for large row counts (ALI-555: track partial progress)
1518
+ let totalAppended = 0
1519
+ let lastResult: { dataset: { id: string; name: string; rowCount: number } } | null = null
1520
+ const totalChunks = Math.ceil(rows.length / APPEND_CHUNK_SIZE_ROWS)
1262
1521
 
1263
- console.log(
1264
- `Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`
1265
- )
1522
+ for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1523
+ const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS)
1524
+ const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1
1525
+
1526
+ console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`)
1527
+ try {
1528
+ const data = await appendChunk(datasetId, chunk)
1529
+ totalAppended += data.appendedCount
1530
+ lastResult = data
1531
+ } catch (error) {
1532
+ const msg = error instanceof Error ? error.message : String(error)
1533
+ throw new Error(
1534
+ `Chunk ${chunkIndex}/${totalChunks} failed: ${msg}\n` +
1535
+ `${totalAppended} rows from ${chunkIndex - 1} chunk(s) were already appended. ` +
1536
+ `To retry, remove the first ${totalAppended} rows from your file and re-run the command.`
1537
+ )
1538
+ }
1539
+ }
1540
+
1541
+ if (lastResult) {
1542
+ console.log(
1543
+ `Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`
1544
+ )
1545
+ }
1266
1546
  }
1267
1547
 
1268
1548
  async function editDatasetRows() {
@@ -1554,6 +1834,11 @@ async function main() {
1554
1834
  return
1555
1835
  }
1556
1836
 
1837
+ if (command === 'apps' && subcommand === 'detail') {
1838
+ await appDetail()
1839
+ return
1840
+ }
1841
+
1557
1842
  if (command === 'tasks' && subcommand === 'list') {
1558
1843
  await listTasks()
1559
1844
  return
@@ -1574,6 +1859,16 @@ async function main() {
1574
1859
  return
1575
1860
  }
1576
1861
 
1862
+ if (command === 'tasks' && subcommand === 'pause') {
1863
+ await updateTaskStatus('paused')
1864
+ return
1865
+ }
1866
+
1867
+ if (command === 'tasks' && subcommand === 'unpause') {
1868
+ await updateTaskStatus('active')
1869
+ return
1870
+ }
1871
+
1577
1872
  if (command === 'datasets' && subcommand === 'upload') {
1578
1873
  await uploadDataset()
1579
1874
  return
@@ -0,0 +1,115 @@
1
+ export interface TaskCreateErrorPayload {
2
+ error?: string
3
+ invalidAssigneeErrors?: Array<{
4
+ assignee?: string
5
+ reason?: string
6
+ }>
7
+ missingRequiredFields?: string[]
8
+ incompatibleRowCount?: number
9
+ missingRequiredFieldCounts?: Record<string, number>
10
+ versionId?: string
11
+ versionNum?: number
12
+ }
13
+
14
+ export interface CliErrorWithStatus extends Error {
15
+ httpStatus?: number
16
+ structuredPayload?: Record<string, unknown>
17
+ }
18
+
19
+ function createCliError(
20
+ message: string,
21
+ httpStatus?: number,
22
+ structuredPayload?: Record<string, unknown>
23
+ ): CliErrorWithStatus {
24
+ const error = new Error(message) as CliErrorWithStatus
25
+ error.httpStatus = httpStatus
26
+ error.structuredPayload = structuredPayload
27
+ return error
28
+ }
29
+
30
+ export async function formatTaskCreateError(response: Response): Promise<CliErrorWithStatus> {
31
+ const contentType = response.headers.get('content-type') || ''
32
+ const rawBody = await response.text()
33
+ const httpStatus = response.status
34
+ let parsedPayload: Record<string, unknown> | undefined
35
+
36
+ if (!contentType.includes('application/json')) {
37
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus)
38
+ }
39
+
40
+ try {
41
+ const payload = JSON.parse(rawBody) as TaskCreateErrorPayload
42
+ parsedPayload = payload as unknown as Record<string, unknown>
43
+
44
+ if (
45
+ Array.isArray(payload.invalidAssigneeErrors) &&
46
+ payload.invalidAssigneeErrors.length > 0
47
+ ) {
48
+ const details = payload.invalidAssigneeErrors
49
+ .filter(
50
+ item =>
51
+ typeof item.assignee === 'string' &&
52
+ item.assignee.length > 0 &&
53
+ typeof item.reason === 'string' &&
54
+ item.reason.length > 0
55
+ )
56
+ .map(item => ` - ${item.assignee}: ${item.reason}`)
57
+ .join('\n')
58
+
59
+ if (details) {
60
+ return createCliError(
61
+ `Failed to create task: ${payload.error || 'Invalid assignees'}\n${details}`,
62
+ httpStatus,
63
+ parsedPayload
64
+ )
65
+ }
66
+ }
67
+
68
+ if (
69
+ Array.isArray(payload.missingRequiredFields) &&
70
+ payload.missingRequiredFields.length > 0
71
+ ) {
72
+ const lines: string[] = [
73
+ `Failed to create task: ${payload.error || 'Dataset incompatible'}`,
74
+ ]
75
+
76
+ if (payload.versionNum !== undefined) {
77
+ lines.push(` Version: v${payload.versionNum} (${payload.versionId || 'unknown'})`)
78
+ }
79
+
80
+ lines.push(` Missing required fields: ${payload.missingRequiredFields.join(', ')}`)
81
+
82
+ if (typeof payload.incompatibleRowCount === 'number') {
83
+ lines.push(` Incompatible rows: ${payload.incompatibleRowCount}`)
84
+ }
85
+
86
+ if (
87
+ payload.missingRequiredFieldCounts &&
88
+ Object.keys(payload.missingRequiredFieldCounts).length > 0
89
+ ) {
90
+ const fieldCounts = Object.entries(payload.missingRequiredFieldCounts)
91
+ .map(([field, count]) => ` ${field}: ${count} rows`)
92
+ .join('\n')
93
+ lines.push(` Missing field counts:\n${fieldCounts}`)
94
+ }
95
+
96
+ return createCliError(
97
+ lines.join('\n'),
98
+ httpStatus,
99
+ parsedPayload
100
+ )
101
+ }
102
+
103
+ if (typeof payload.error === 'string' && payload.error.length > 0) {
104
+ return createCliError(
105
+ `Failed to create task: ${payload.error}`,
106
+ httpStatus,
107
+ parsedPayload
108
+ )
109
+ }
110
+ } catch {
111
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus)
112
+ }
113
+
114
+ return createCliError(`Failed to create task: ${rawBody}`, httpStatus, parsedPayload)
115
+ }