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.
- package/dist/credentials.js +10 -2
- package/dist/error-response.js +20 -0
- package/dist/index.js +223 -23
- package/dist/task-create-error.js +60 -0
- package/package.json +1 -1
- package/src/credentials.ts +11 -2
- package/src/error-response.ts +27 -0
- package/src/index.ts +326 -31
- package/src/task-create-error.ts +115 -0
package/dist/credentials.js
CHANGED
|
@@ -68,14 +68,22 @@ export function loadCredentialsConfig() {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
const raw = readFileSync(path, 'utf-8');
|
|
71
|
-
|
|
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
|
-
|
|
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 <
|
|
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 || '/',
|
|
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',
|
|
369
|
-
|
|
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 =
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
if (
|
|
859
|
-
|
|
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
package/src/credentials.ts
CHANGED
|
@@ -82,7 +82,15 @@ export function loadCredentialsConfig(): StoredCredentialsV2 | null {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const raw = readFileSync(path, 'utf-8')
|
|
85
|
-
|
|
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
|
-
|
|
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 <
|
|
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(
|
|
571
|
-
|
|
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(
|
|
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 || '/',
|
|
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',
|
|
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(
|
|
663
|
+
server.listen(callbackPort, '127.0.0.1', async () => {
|
|
628
664
|
try {
|
|
629
|
-
const redirectUri =
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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: {
|
|
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}]
|
|
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
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
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
|
+
}
|