orizu 0.0.4 → 0.0.6
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 +102 -5
- package/dist/dataset-download.js +24 -0
- package/dist/global-flags.js +57 -0
- package/dist/http.js +32 -12
- package/dist/index.js +94 -22
- package/package.json +1 -1
- package/src/credentials.ts +123 -6
- package/src/dataset-download.ts +36 -0
- package/src/global-flags.ts +81 -0
- package/src/http.ts +39 -13
- package/src/index.ts +132 -22
- package/src/types.ts +16 -1
package/dist/credentials.js
CHANGED
|
@@ -2,27 +2,124 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync }
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
function getConfigDir() {
|
|
5
|
+
if (process.env.ORIZU_CONFIG_DIR) {
|
|
6
|
+
return process.env.ORIZU_CONFIG_DIR;
|
|
7
|
+
}
|
|
5
8
|
return join(homedir(), '.config', 'orizu');
|
|
6
9
|
}
|
|
7
10
|
function getCredentialsPath() {
|
|
8
11
|
return join(getConfigDir(), 'credentials.json');
|
|
9
12
|
}
|
|
10
|
-
|
|
13
|
+
function isStoredCredentialsV2(value) {
|
|
14
|
+
if (!value || typeof value !== 'object') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const typed = value;
|
|
18
|
+
return typed.version === 2 && !!typed.servers && typeof typed.servers === 'object';
|
|
19
|
+
}
|
|
20
|
+
function isStoredCredentialsV1(value) {
|
|
21
|
+
if (!value || typeof value !== 'object') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const typed = value;
|
|
25
|
+
return (typeof typed.baseUrl === 'string' &&
|
|
26
|
+
typeof typed.accessToken === 'string' &&
|
|
27
|
+
typeof typed.refreshToken === 'string' &&
|
|
28
|
+
typeof typed.expiresAt === 'number');
|
|
29
|
+
}
|
|
30
|
+
function migrateToV2(stored) {
|
|
31
|
+
return {
|
|
32
|
+
version: 2,
|
|
33
|
+
activeBaseUrl: stored.baseUrl,
|
|
34
|
+
servers: {
|
|
35
|
+
[stored.baseUrl]: {
|
|
36
|
+
accessToken: stored.accessToken,
|
|
37
|
+
refreshToken: stored.refreshToken,
|
|
38
|
+
expiresAt: stored.expiresAt,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function writeCredentials(config) {
|
|
11
44
|
const dir = getConfigDir();
|
|
12
45
|
mkdirSync(dir, { recursive: true });
|
|
13
46
|
const path = getCredentialsPath();
|
|
14
|
-
writeFileSync(path, JSON.stringify(
|
|
47
|
+
writeFileSync(path, JSON.stringify(config, null, 2), 'utf-8');
|
|
15
48
|
chmodSync(path, 0o600);
|
|
16
49
|
}
|
|
17
|
-
|
|
50
|
+
function createEmptyCredentialsConfig() {
|
|
51
|
+
return {
|
|
52
|
+
version: 2,
|
|
53
|
+
activeBaseUrl: null,
|
|
54
|
+
servers: {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function loadCredentialsConfigForWrite() {
|
|
58
|
+
try {
|
|
59
|
+
return loadCredentialsConfig() || createEmptyCredentialsConfig();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return createEmptyCredentialsConfig();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function loadCredentialsConfig() {
|
|
18
66
|
const path = getCredentialsPath();
|
|
19
67
|
if (!existsSync(path)) {
|
|
20
68
|
return null;
|
|
21
69
|
}
|
|
22
70
|
const raw = readFileSync(path, 'utf-8');
|
|
23
|
-
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
if (isStoredCredentialsV2(parsed)) {
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
if (isStoredCredentialsV1(parsed)) {
|
|
76
|
+
return migrateToV2(parsed);
|
|
77
|
+
}
|
|
78
|
+
throw new Error('Invalid credentials file format.');
|
|
79
|
+
}
|
|
80
|
+
export function getServerCredentials(baseUrl) {
|
|
81
|
+
const config = loadCredentialsConfig();
|
|
82
|
+
if (!config) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!Object.hasOwn(config.servers, baseUrl)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return config.servers[baseUrl] || null;
|
|
89
|
+
}
|
|
90
|
+
export function saveServerCredentials(baseUrl, credentials) {
|
|
91
|
+
const config = loadCredentialsConfigForWrite();
|
|
92
|
+
config.servers[baseUrl] = credentials;
|
|
93
|
+
config.activeBaseUrl = baseUrl;
|
|
94
|
+
writeCredentials(config);
|
|
95
|
+
}
|
|
96
|
+
export function updateServerCredentials(baseUrl, credentials) {
|
|
97
|
+
const config = loadCredentialsConfigForWrite();
|
|
98
|
+
config.servers[baseUrl] = credentials;
|
|
99
|
+
writeCredentials(config);
|
|
100
|
+
}
|
|
101
|
+
export function getActiveBaseUrl() {
|
|
102
|
+
const config = loadCredentialsConfig();
|
|
103
|
+
return config?.activeBaseUrl || null;
|
|
104
|
+
}
|
|
105
|
+
export function setActiveBaseUrl(baseUrl) {
|
|
106
|
+
const config = loadCredentialsConfigForWrite();
|
|
107
|
+
config.activeBaseUrl = baseUrl;
|
|
108
|
+
writeCredentials(config);
|
|
109
|
+
}
|
|
110
|
+
export function clearServerCredentials(baseUrl) {
|
|
111
|
+
const config = loadCredentialsConfig();
|
|
112
|
+
if (!config || !Object.hasOwn(config.servers, baseUrl)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
delete config.servers[baseUrl];
|
|
116
|
+
if (config.activeBaseUrl === baseUrl) {
|
|
117
|
+
config.activeBaseUrl = null;
|
|
118
|
+
}
|
|
119
|
+
writeCredentials(config);
|
|
120
|
+
return true;
|
|
24
121
|
}
|
|
25
|
-
export function
|
|
122
|
+
export function clearCredentialsFile() {
|
|
26
123
|
const path = getCredentialsPath();
|
|
27
124
|
if (existsSync(path)) {
|
|
28
125
|
rmSync(path);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const DATASET_URL_PATH = /^\/d\/([^/]+)\/([^/]+)\/datasets\/([^/?#]+)\/?$/;
|
|
2
|
+
export function parseDatasetReference(input) {
|
|
3
|
+
const value = input.trim();
|
|
4
|
+
if (!value) {
|
|
5
|
+
throw new Error('Dataset value cannot be empty');
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
const match = DATASET_URL_PATH.exec(url.pathname);
|
|
10
|
+
if (match) {
|
|
11
|
+
return {
|
|
12
|
+
project: `${match[1]}/${match[2]}`,
|
|
13
|
+
datasetId: decodeURIComponent(match[3]),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
throw new Error('Dataset URL must be in format https://<host>/d/<teamSlug>/<projectSlug>/datasets/<datasetId>');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
20
|
+
throw new Error('Dataset URL must be in format https://<host>/d/<teamSlug>/<projectSlug>/datasets/<datasetId>');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { datasetId: value };
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const LOCALHOST_BASE_URL = 'http://localhost:3000';
|
|
2
|
+
export function normalizeBaseUrl(url) {
|
|
3
|
+
let parsed;
|
|
4
|
+
try {
|
|
5
|
+
parsed = new URL(url);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error(`Invalid server URL: '${url}'`);
|
|
9
|
+
}
|
|
10
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
11
|
+
throw new Error('Server URL must use http or https.');
|
|
12
|
+
}
|
|
13
|
+
if (parsed.username || parsed.password) {
|
|
14
|
+
throw new Error('Server URL must not contain credentials.');
|
|
15
|
+
}
|
|
16
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
17
|
+
throw new Error('Server URL must be an origin only (no path, query, or hash).');
|
|
18
|
+
}
|
|
19
|
+
return parsed.origin;
|
|
20
|
+
}
|
|
21
|
+
export function parseGlobalFlags(argv) {
|
|
22
|
+
const args = [];
|
|
23
|
+
const flags = {
|
|
24
|
+
local: false,
|
|
25
|
+
server: null,
|
|
26
|
+
};
|
|
27
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
28
|
+
const value = argv[index];
|
|
29
|
+
if (value === '--local') {
|
|
30
|
+
flags.local = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (value === '--server') {
|
|
34
|
+
const next = argv[index + 1];
|
|
35
|
+
if (!next || next.startsWith('--')) {
|
|
36
|
+
throw new Error('Usage: --server <url>');
|
|
37
|
+
}
|
|
38
|
+
flags.server = normalizeBaseUrl(next);
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
args.push(value);
|
|
43
|
+
}
|
|
44
|
+
if (flags.local && flags.server) {
|
|
45
|
+
throw new Error('Use either --local or --server <url>, not both.');
|
|
46
|
+
}
|
|
47
|
+
return { flags, args };
|
|
48
|
+
}
|
|
49
|
+
export function getFlagBaseUrl(flags) {
|
|
50
|
+
if (flags.local) {
|
|
51
|
+
return LOCALHOST_BASE_URL;
|
|
52
|
+
}
|
|
53
|
+
if (flags.server) {
|
|
54
|
+
return flags.server;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getActiveBaseUrl, getServerCredentials, updateServerCredentials } from './credentials.js';
|
|
2
|
+
import { getFlagBaseUrl, normalizeBaseUrl } from './global-flags.js';
|
|
3
|
+
let runtimeFlags = { local: false, server: null };
|
|
4
|
+
export function setGlobalFlags(flags) {
|
|
5
|
+
runtimeFlags = flags;
|
|
6
|
+
}
|
|
7
|
+
export function resolveBaseUrl(flags = runtimeFlags) {
|
|
8
|
+
const fromFlags = getFlagBaseUrl(flags);
|
|
9
|
+
if (fromFlags) {
|
|
10
|
+
return fromFlags;
|
|
11
|
+
}
|
|
12
|
+
const fromEnv = process.env.ORIZU_BASE_URL;
|
|
13
|
+
if (fromEnv) {
|
|
14
|
+
return normalizeBaseUrl(fromEnv);
|
|
15
|
+
}
|
|
16
|
+
const fromStored = getActiveBaseUrl();
|
|
17
|
+
if (fromStored) {
|
|
18
|
+
return fromStored;
|
|
19
|
+
}
|
|
20
|
+
return 'https://orizu.ai';
|
|
21
|
+
}
|
|
2
22
|
export function getBaseUrl() {
|
|
3
|
-
return
|
|
23
|
+
return resolveBaseUrl();
|
|
4
24
|
}
|
|
5
25
|
function isExpired(expiresAt) {
|
|
6
26
|
const nowUnix = Math.floor(Date.now() / 1000);
|
|
7
27
|
return expiresAt <= nowUnix + 30;
|
|
8
28
|
}
|
|
9
|
-
async function refreshCredentials(credentials) {
|
|
10
|
-
const response = await fetch(`${
|
|
29
|
+
async function refreshCredentials(baseUrl, credentials) {
|
|
30
|
+
const response = await fetch(`${baseUrl}/api/cli/auth/refresh`, {
|
|
11
31
|
method: 'POST',
|
|
12
32
|
headers: { 'Content-Type': 'application/json' },
|
|
13
33
|
body: JSON.stringify({ refreshToken: credentials.refreshToken }),
|
|
@@ -20,21 +40,21 @@ async function refreshCredentials(credentials) {
|
|
|
20
40
|
accessToken: data.accessToken,
|
|
21
41
|
refreshToken: data.refreshToken,
|
|
22
42
|
expiresAt: data.expiresAt,
|
|
23
|
-
baseUrl: credentials.baseUrl,
|
|
24
43
|
};
|
|
25
|
-
|
|
44
|
+
updateServerCredentials(baseUrl, refreshed);
|
|
26
45
|
return refreshed;
|
|
27
46
|
}
|
|
28
47
|
export async function authedFetch(path, init = {}) {
|
|
29
|
-
const
|
|
48
|
+
const baseUrl = resolveBaseUrl();
|
|
49
|
+
const credentials = getServerCredentials(baseUrl);
|
|
30
50
|
if (!credentials) {
|
|
31
|
-
throw new Error(
|
|
51
|
+
throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`);
|
|
32
52
|
}
|
|
33
53
|
let activeCredentials = credentials;
|
|
34
54
|
if (isExpired(activeCredentials.expiresAt)) {
|
|
35
|
-
activeCredentials = await refreshCredentials(activeCredentials);
|
|
55
|
+
activeCredentials = await refreshCredentials(baseUrl, activeCredentials);
|
|
36
56
|
}
|
|
37
|
-
let response = await fetch(`${
|
|
57
|
+
let response = await fetch(`${baseUrl}${path}`, {
|
|
38
58
|
...init,
|
|
39
59
|
headers: {
|
|
40
60
|
...(init.headers || {}),
|
|
@@ -42,8 +62,8 @@ export async function authedFetch(path, init = {}) {
|
|
|
42
62
|
},
|
|
43
63
|
});
|
|
44
64
|
if (response.status === 401) {
|
|
45
|
-
activeCredentials = await refreshCredentials(activeCredentials);
|
|
46
|
-
response = await fetch(`${
|
|
65
|
+
activeCredentials = await refreshCredentials(baseUrl, activeCredentials);
|
|
66
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
47
67
|
...init,
|
|
48
68
|
headers: {
|
|
49
69
|
...(init.headers || {}),
|
package/dist/index.js
CHANGED
|
@@ -6,24 +6,27 @@ import { readFileSync, 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
|
-
import {
|
|
9
|
+
import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js';
|
|
10
10
|
import { parseDatasetFile } from './file-parser.js';
|
|
11
|
-
import {
|
|
11
|
+
import { parseDatasetReference } from './dataset-download.js';
|
|
12
|
+
import { parseGlobalFlags } from './global-flags.js';
|
|
13
|
+
import { authedFetch, getBaseUrl, setGlobalFlags } from './http.js';
|
|
12
14
|
function printUsage() {
|
|
13
|
-
console.log(`orizu 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> --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> [--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 tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`);
|
|
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 tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`);
|
|
14
16
|
}
|
|
17
|
+
let cliArgs = process.argv.slice(2);
|
|
15
18
|
function getArg(name) {
|
|
16
|
-
const index =
|
|
17
|
-
if (index === -1 || index + 1 >=
|
|
19
|
+
const index = cliArgs.indexOf(name);
|
|
20
|
+
if (index === -1 || index + 1 >= cliArgs.length) {
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
|
-
return
|
|
23
|
+
return cliArgs[index + 1];
|
|
21
24
|
}
|
|
22
25
|
function isInteractiveTerminal() {
|
|
23
26
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
24
27
|
}
|
|
25
28
|
function hasArg(name) {
|
|
26
|
-
return
|
|
29
|
+
return cliArgs.includes(name);
|
|
27
30
|
}
|
|
28
31
|
function expandHomePath(path) {
|
|
29
32
|
if (path.startsWith('~/')) {
|
|
@@ -144,6 +147,14 @@ async function fetchApps(project) {
|
|
|
144
147
|
const data = await parseJsonResponse(response, 'Apps list');
|
|
145
148
|
return data.apps;
|
|
146
149
|
}
|
|
150
|
+
async function fetchDatasets(project) {
|
|
151
|
+
const response = await authedFetch(`/api/cli/datasets?project=${encodeURIComponent(project)}`);
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new Error(`Failed to fetch datasets: ${await response.text()}`);
|
|
154
|
+
}
|
|
155
|
+
const data = await parseJsonResponse(response, 'Datasets list');
|
|
156
|
+
return data.datasets;
|
|
157
|
+
}
|
|
147
158
|
async function fetchTeamMembers(teamSlug) {
|
|
148
159
|
const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`);
|
|
149
160
|
if (!response.ok) {
|
|
@@ -204,6 +215,18 @@ async function selectAppIdInteractively(projectArg) {
|
|
|
204
215
|
project,
|
|
205
216
|
};
|
|
206
217
|
}
|
|
218
|
+
async function selectDatasetInteractively(projectArg) {
|
|
219
|
+
let project = projectArg;
|
|
220
|
+
if (!project) {
|
|
221
|
+
project = await resolveProjectSlug(null);
|
|
222
|
+
}
|
|
223
|
+
const datasets = await fetchDatasets(project);
|
|
224
|
+
const dataset = await promptSelect(`Select a dataset in ${project}`, datasets, item => `${item.name} (id=${item.id}, rows=${item.rowCount})`, { forcePrompt: true });
|
|
225
|
+
return {
|
|
226
|
+
datasetId: dataset.id,
|
|
227
|
+
project,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
207
230
|
function printTeams(teams) {
|
|
208
231
|
if (teams.length === 0) {
|
|
209
232
|
console.log('No teams found.');
|
|
@@ -377,11 +400,10 @@ async function login() {
|
|
|
377
400
|
throw new Error(`Failed to exchange auth code: ${text}`);
|
|
378
401
|
}
|
|
379
402
|
const loginData = await parseJsonResponse(exchangeResponse, 'CLI auth exchange');
|
|
380
|
-
|
|
403
|
+
saveServerCredentials(baseUrl, {
|
|
381
404
|
accessToken: loginData.accessToken,
|
|
382
405
|
refreshToken: loginData.refreshToken,
|
|
383
406
|
expiresAt: loginData.expiresAt,
|
|
384
|
-
baseUrl,
|
|
385
407
|
});
|
|
386
408
|
console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`);
|
|
387
409
|
}
|
|
@@ -394,19 +416,20 @@ async function whoami() {
|
|
|
394
416
|
console.log(data.user.email ?? data.user.id);
|
|
395
417
|
}
|
|
396
418
|
async function logout() {
|
|
397
|
-
const
|
|
419
|
+
const baseUrl = getBaseUrl();
|
|
420
|
+
const credentials = getServerCredentials(baseUrl);
|
|
398
421
|
if (!credentials) {
|
|
399
|
-
console.log(
|
|
422
|
+
console.log(`Already logged out for ${baseUrl}.`);
|
|
400
423
|
return;
|
|
401
424
|
}
|
|
402
|
-
await fetch(`${
|
|
425
|
+
await fetch(`${baseUrl}/api/cli/auth/logout`, {
|
|
403
426
|
method: 'POST',
|
|
404
427
|
headers: {
|
|
405
428
|
Authorization: `Bearer ${credentials.accessToken}`,
|
|
406
429
|
},
|
|
407
430
|
}).catch(() => undefined);
|
|
408
|
-
|
|
409
|
-
console.log(
|
|
431
|
+
clearServerCredentials(baseUrl);
|
|
432
|
+
console.log(`Logged out from ${baseUrl}.`);
|
|
410
433
|
}
|
|
411
434
|
async function listTeams() {
|
|
412
435
|
printTeams(await fetchTeams());
|
|
@@ -504,12 +527,13 @@ function readJsonFile(pathArg) {
|
|
|
504
527
|
async function createAppFromFile() {
|
|
505
528
|
const project = getArg('--project');
|
|
506
529
|
const name = getArg('--name');
|
|
530
|
+
const datasetId = getArg('--dataset');
|
|
507
531
|
const filePath = getArg('--file');
|
|
508
532
|
const inputSchemaPath = getArg('--input-schema');
|
|
509
533
|
const outputSchemaPath = getArg('--output-schema');
|
|
510
534
|
const component = getArg('--component') || undefined;
|
|
511
|
-
if (!project || !name || !filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
512
|
-
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]');
|
|
535
|
+
if (!project || !name || !datasetId || !filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
536
|
+
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]');
|
|
513
537
|
}
|
|
514
538
|
const sourceCode = readSourceFile(filePath);
|
|
515
539
|
const inputJsonSchema = readJsonFile(inputSchemaPath);
|
|
@@ -520,6 +544,7 @@ async function createAppFromFile() {
|
|
|
520
544
|
body: JSON.stringify({
|
|
521
545
|
projectSlug: project,
|
|
522
546
|
name,
|
|
547
|
+
datasetId,
|
|
523
548
|
sourceCode,
|
|
524
549
|
componentName: component,
|
|
525
550
|
inputJsonSchema,
|
|
@@ -612,11 +637,12 @@ async function createTask() {
|
|
|
612
637
|
const datasetId = getArg('--dataset');
|
|
613
638
|
const appId = getArg('--app');
|
|
614
639
|
const title = getArg('--title');
|
|
640
|
+
const assignees = parseCommaSeparated(getArg('--assignees'));
|
|
615
641
|
const instructions = getArg('--instructions');
|
|
616
642
|
const labelsPerItemArg = getArg('--labels-per-item');
|
|
617
643
|
const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1;
|
|
618
|
-
if (!projectSlug || !datasetId || !appId || !title) {
|
|
619
|
-
throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--instructions <text>] [--labels-per-item <n>]');
|
|
644
|
+
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>]');
|
|
620
646
|
}
|
|
621
647
|
const response = await authedFetch('/api/cli/tasks', {
|
|
622
648
|
method: 'POST',
|
|
@@ -626,6 +652,7 @@ async function createTask() {
|
|
|
626
652
|
datasetId,
|
|
627
653
|
appId,
|
|
628
654
|
title,
|
|
655
|
+
memberIds: assignees,
|
|
629
656
|
instructions,
|
|
630
657
|
requiredAssignmentsPerRow: labelsPerItem,
|
|
631
658
|
}),
|
|
@@ -634,7 +661,7 @@ async function createTask() {
|
|
|
634
661
|
throw new Error(`Failed to create task: ${await response.text()}`);
|
|
635
662
|
}
|
|
636
663
|
const data = await parseJsonResponse(response, 'Task create');
|
|
637
|
-
console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}`);
|
|
664
|
+
console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`);
|
|
638
665
|
}
|
|
639
666
|
async function assignTask() {
|
|
640
667
|
const taskId = getArg('--task');
|
|
@@ -765,6 +792,44 @@ async function uploadDataset() {
|
|
|
765
792
|
console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
|
|
766
793
|
}
|
|
767
794
|
}
|
|
795
|
+
function getDatasetDownloadInput() {
|
|
796
|
+
const fromFlag = getArg('--dataset');
|
|
797
|
+
if (fromFlag) {
|
|
798
|
+
return fromFlag;
|
|
799
|
+
}
|
|
800
|
+
const positional = cliArgs[2];
|
|
801
|
+
if (positional && !positional.startsWith('--')) {
|
|
802
|
+
return positional;
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
async function downloadDataset() {
|
|
807
|
+
const projectArg = getArg('--project');
|
|
808
|
+
const datasetInput = getDatasetDownloadInput();
|
|
809
|
+
const format = (getArg('--format') || 'jsonl');
|
|
810
|
+
const outPathArg = getArg('--out');
|
|
811
|
+
if (!['csv', 'json', 'jsonl'].includes(format)) {
|
|
812
|
+
throw new Error('format must be one of: csv, json, jsonl');
|
|
813
|
+
}
|
|
814
|
+
let datasetId;
|
|
815
|
+
if (datasetInput) {
|
|
816
|
+
datasetId = parseDatasetReference(datasetInput).datasetId;
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const selected = await selectDatasetInteractively(projectArg);
|
|
820
|
+
datasetId = selected.datasetId;
|
|
821
|
+
}
|
|
822
|
+
const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/download?format=${encodeURIComponent(format)}`);
|
|
823
|
+
if (!response.ok) {
|
|
824
|
+
throw new Error(`Download failed: ${await response.text()}`);
|
|
825
|
+
}
|
|
826
|
+
const filename = outPathArg
|
|
827
|
+
? expandHomePath(outPathArg)
|
|
828
|
+
: `${datasetId}.${format}`;
|
|
829
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
830
|
+
writeFileSync(filename, bytes);
|
|
831
|
+
console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`);
|
|
832
|
+
}
|
|
768
833
|
async function downloadAnnotations() {
|
|
769
834
|
let taskId = getArg('--task');
|
|
770
835
|
const format = (getArg('--format') || 'jsonl');
|
|
@@ -788,8 +853,11 @@ async function downloadAnnotations() {
|
|
|
788
853
|
console.log(`Saved ${format.toUpperCase()} export to ${filename}`);
|
|
789
854
|
}
|
|
790
855
|
async function main() {
|
|
791
|
-
const
|
|
792
|
-
|
|
856
|
+
const parsed = parseGlobalFlags(process.argv.slice(2));
|
|
857
|
+
setGlobalFlags(parsed.flags);
|
|
858
|
+
cliArgs = parsed.args;
|
|
859
|
+
const command = cliArgs[0];
|
|
860
|
+
const subcommand = cliArgs[1];
|
|
793
861
|
if (!command) {
|
|
794
862
|
printUsage();
|
|
795
863
|
process.exit(1);
|
|
@@ -814,7 +882,7 @@ async function main() {
|
|
|
814
882
|
await createTeam();
|
|
815
883
|
return;
|
|
816
884
|
}
|
|
817
|
-
const teamsMembersAction =
|
|
885
|
+
const teamsMembersAction = cliArgs[2];
|
|
818
886
|
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
|
|
819
887
|
await listTeamMembers();
|
|
820
888
|
return;
|
|
@@ -875,6 +943,10 @@ async function main() {
|
|
|
875
943
|
await uploadDataset();
|
|
876
944
|
return;
|
|
877
945
|
}
|
|
946
|
+
if (command === 'datasets' && subcommand === 'download') {
|
|
947
|
+
await downloadDataset();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
878
950
|
if (command === 'tasks' && subcommand === 'export') {
|
|
879
951
|
await downloadAnnotations();
|
|
880
952
|
return;
|
package/package.json
CHANGED
package/src/credentials.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'fs'
|
|
2
2
|
import { join } from 'path'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
|
-
import {
|
|
4
|
+
import { ServerCredentials, StoredCredentialsV1, StoredCredentialsV2 } from './types.js'
|
|
5
5
|
|
|
6
6
|
function getConfigDir(): string {
|
|
7
|
+
if (process.env.ORIZU_CONFIG_DIR) {
|
|
8
|
+
return process.env.ORIZU_CONFIG_DIR
|
|
9
|
+
}
|
|
7
10
|
return join(homedir(), '.config', 'orizu')
|
|
8
11
|
}
|
|
9
12
|
|
|
@@ -11,25 +14,139 @@ function getCredentialsPath(): string {
|
|
|
11
14
|
return join(getConfigDir(), 'credentials.json')
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
function isStoredCredentialsV2(value: unknown): value is StoredCredentialsV2 {
|
|
18
|
+
if (!value || typeof value !== 'object') {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const typed = value as Partial<StoredCredentialsV2>
|
|
23
|
+
return typed.version === 2 && !!typed.servers && typeof typed.servers === 'object'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isStoredCredentialsV1(value: unknown): value is StoredCredentialsV1 {
|
|
27
|
+
if (!value || typeof value !== 'object') {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const typed = value as Partial<StoredCredentialsV1>
|
|
32
|
+
return (
|
|
33
|
+
typeof typed.baseUrl === 'string' &&
|
|
34
|
+
typeof typed.accessToken === 'string' &&
|
|
35
|
+
typeof typed.refreshToken === 'string' &&
|
|
36
|
+
typeof typed.expiresAt === 'number'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function migrateToV2(stored: StoredCredentialsV1): StoredCredentialsV2 {
|
|
41
|
+
return {
|
|
42
|
+
version: 2,
|
|
43
|
+
activeBaseUrl: stored.baseUrl,
|
|
44
|
+
servers: {
|
|
45
|
+
[stored.baseUrl]: {
|
|
46
|
+
accessToken: stored.accessToken,
|
|
47
|
+
refreshToken: stored.refreshToken,
|
|
48
|
+
expiresAt: stored.expiresAt,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeCredentials(config: StoredCredentialsV2) {
|
|
15
55
|
const dir = getConfigDir()
|
|
16
56
|
mkdirSync(dir, { recursive: true })
|
|
17
57
|
const path = getCredentialsPath()
|
|
18
|
-
writeFileSync(path, JSON.stringify(
|
|
58
|
+
writeFileSync(path, JSON.stringify(config, null, 2), 'utf-8')
|
|
19
59
|
chmodSync(path, 0o600)
|
|
20
60
|
}
|
|
21
61
|
|
|
22
|
-
|
|
62
|
+
function createEmptyCredentialsConfig(): StoredCredentialsV2 {
|
|
63
|
+
return {
|
|
64
|
+
version: 2 as const,
|
|
65
|
+
activeBaseUrl: null,
|
|
66
|
+
servers: {},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function loadCredentialsConfigForWrite(): StoredCredentialsV2 {
|
|
71
|
+
try {
|
|
72
|
+
return loadCredentialsConfig() || createEmptyCredentialsConfig()
|
|
73
|
+
} catch {
|
|
74
|
+
return createEmptyCredentialsConfig()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadCredentialsConfig(): StoredCredentialsV2 | null {
|
|
23
79
|
const path = getCredentialsPath()
|
|
24
80
|
if (!existsSync(path)) {
|
|
25
81
|
return null
|
|
26
82
|
}
|
|
27
83
|
|
|
28
84
|
const raw = readFileSync(path, 'utf-8')
|
|
29
|
-
|
|
85
|
+
const parsed = JSON.parse(raw) as unknown
|
|
86
|
+
if (isStoredCredentialsV2(parsed)) {
|
|
87
|
+
return parsed
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isStoredCredentialsV1(parsed)) {
|
|
91
|
+
return migrateToV2(parsed)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error('Invalid credentials file format.')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getServerCredentials(baseUrl: string): ServerCredentials | null {
|
|
98
|
+
const config = loadCredentialsConfig()
|
|
99
|
+
if (!config) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!Object.hasOwn(config.servers, baseUrl)) {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return config.servers[baseUrl] || null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function saveServerCredentials(baseUrl: string, credentials: ServerCredentials) {
|
|
111
|
+
const config = loadCredentialsConfigForWrite()
|
|
112
|
+
|
|
113
|
+
config.servers[baseUrl] = credentials
|
|
114
|
+
config.activeBaseUrl = baseUrl
|
|
115
|
+
writeCredentials(config)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function updateServerCredentials(baseUrl: string, credentials: ServerCredentials) {
|
|
119
|
+
const config = loadCredentialsConfigForWrite()
|
|
120
|
+
config.servers[baseUrl] = credentials
|
|
121
|
+
writeCredentials(config)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getActiveBaseUrl(): string | null {
|
|
125
|
+
const config = loadCredentialsConfig()
|
|
126
|
+
return config?.activeBaseUrl || null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function setActiveBaseUrl(baseUrl: string | null) {
|
|
130
|
+
const config = loadCredentialsConfigForWrite()
|
|
131
|
+
config.activeBaseUrl = baseUrl
|
|
132
|
+
writeCredentials(config)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearServerCredentials(baseUrl: string): boolean {
|
|
136
|
+
const config = loadCredentialsConfig()
|
|
137
|
+
if (!config || !Object.hasOwn(config.servers, baseUrl)) {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
delete config.servers[baseUrl]
|
|
142
|
+
if (config.activeBaseUrl === baseUrl) {
|
|
143
|
+
config.activeBaseUrl = null
|
|
144
|
+
}
|
|
145
|
+
writeCredentials(config)
|
|
146
|
+
return true
|
|
30
147
|
}
|
|
31
148
|
|
|
32
|
-
export function
|
|
149
|
+
export function clearCredentialsFile() {
|
|
33
150
|
const path = getCredentialsPath()
|
|
34
151
|
if (existsSync(path)) {
|
|
35
152
|
rmSync(path)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ParsedDatasetReference {
|
|
2
|
+
datasetId: string
|
|
3
|
+
project?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const DATASET_URL_PATH = /^\/d\/([^/]+)\/([^/]+)\/datasets\/([^/?#]+)\/?$/
|
|
7
|
+
|
|
8
|
+
export function parseDatasetReference(input: string): ParsedDatasetReference {
|
|
9
|
+
const value = input.trim()
|
|
10
|
+
if (!value) {
|
|
11
|
+
throw new Error('Dataset value cannot be empty')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value)
|
|
16
|
+
const match = DATASET_URL_PATH.exec(url.pathname)
|
|
17
|
+
if (match) {
|
|
18
|
+
return {
|
|
19
|
+
project: `${match[1]}/${match[2]}`,
|
|
20
|
+
datasetId: decodeURIComponent(match[3]),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Dataset URL must be in format https://<host>/d/<teamSlug>/<projectSlug>/datasets/<datasetId>'
|
|
26
|
+
)
|
|
27
|
+
} catch {
|
|
28
|
+
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Dataset URL must be in format https://<host>/d/<teamSlug>/<projectSlug>/datasets/<datasetId>'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { datasetId: value }
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const LOCALHOST_BASE_URL = 'http://localhost:3000'
|
|
2
|
+
|
|
3
|
+
export interface GlobalFlags {
|
|
4
|
+
local: boolean
|
|
5
|
+
server: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ParsedGlobalFlags {
|
|
9
|
+
flags: GlobalFlags
|
|
10
|
+
args: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeBaseUrl(url: string): string {
|
|
14
|
+
let parsed: URL
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(url)
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`Invalid server URL: '${url}'`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
22
|
+
throw new Error('Server URL must use http or https.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (parsed.username || parsed.password) {
|
|
26
|
+
throw new Error('Server URL must not contain credentials.')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
30
|
+
throw new Error('Server URL must be an origin only (no path, query, or hash).')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return parsed.origin
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseGlobalFlags(argv: string[]): ParsedGlobalFlags {
|
|
37
|
+
const args: string[] = []
|
|
38
|
+
const flags: GlobalFlags = {
|
|
39
|
+
local: false,
|
|
40
|
+
server: null,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
44
|
+
const value = argv[index]
|
|
45
|
+
if (value === '--local') {
|
|
46
|
+
flags.local = true
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value === '--server') {
|
|
51
|
+
const next = argv[index + 1]
|
|
52
|
+
if (!next || next.startsWith('--')) {
|
|
53
|
+
throw new Error('Usage: --server <url>')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
flags.server = normalizeBaseUrl(next)
|
|
57
|
+
index += 1
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
args.push(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (flags.local && flags.server) {
|
|
65
|
+
throw new Error('Use either --local or --server <url>, not both.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { flags, args }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getFlagBaseUrl(flags: GlobalFlags): string | null {
|
|
72
|
+
if (flags.local) {
|
|
73
|
+
return LOCALHOST_BASE_URL
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (flags.server) {
|
|
77
|
+
return flags.server
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { getActiveBaseUrl, getServerCredentials, updateServerCredentials } from './credentials.js'
|
|
2
|
+
import { getFlagBaseUrl, GlobalFlags, normalizeBaseUrl } from './global-flags.js'
|
|
3
|
+
import { LoginResponse, ServerCredentials } from './types.js'
|
|
4
|
+
|
|
5
|
+
let runtimeFlags: GlobalFlags = { local: false, server: null }
|
|
6
|
+
|
|
7
|
+
export function setGlobalFlags(flags: GlobalFlags) {
|
|
8
|
+
runtimeFlags = flags
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveBaseUrl(flags: GlobalFlags = runtimeFlags): string {
|
|
12
|
+
const fromFlags = getFlagBaseUrl(flags)
|
|
13
|
+
if (fromFlags) {
|
|
14
|
+
return fromFlags
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fromEnv = process.env.ORIZU_BASE_URL
|
|
18
|
+
if (fromEnv) {
|
|
19
|
+
return normalizeBaseUrl(fromEnv)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fromStored = getActiveBaseUrl()
|
|
23
|
+
if (fromStored) {
|
|
24
|
+
return fromStored
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return 'https://orizu.ai'
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
export function getBaseUrl(): string {
|
|
5
|
-
return
|
|
31
|
+
return resolveBaseUrl()
|
|
6
32
|
}
|
|
7
33
|
|
|
8
34
|
function isExpired(expiresAt: number): boolean {
|
|
@@ -10,8 +36,8 @@ function isExpired(expiresAt: number): boolean {
|
|
|
10
36
|
return expiresAt <= nowUnix + 30
|
|
11
37
|
}
|
|
12
38
|
|
|
13
|
-
async function refreshCredentials(credentials:
|
|
14
|
-
const response = await fetch(`${
|
|
39
|
+
async function refreshCredentials(baseUrl: string, credentials: ServerCredentials): Promise<ServerCredentials> {
|
|
40
|
+
const response = await fetch(`${baseUrl}/api/cli/auth/refresh`, {
|
|
15
41
|
method: 'POST',
|
|
16
42
|
headers: { 'Content-Type': 'application/json' },
|
|
17
43
|
body: JSON.stringify({ refreshToken: credentials.refreshToken }),
|
|
@@ -26,24 +52,24 @@ async function refreshCredentials(credentials: StoredCredentials): Promise<Store
|
|
|
26
52
|
accessToken: data.accessToken,
|
|
27
53
|
refreshToken: data.refreshToken,
|
|
28
54
|
expiresAt: data.expiresAt,
|
|
29
|
-
baseUrl: credentials.baseUrl,
|
|
30
55
|
}
|
|
31
|
-
|
|
56
|
+
updateServerCredentials(baseUrl, refreshed)
|
|
32
57
|
return refreshed
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
export async function authedFetch(path: string, init: RequestInit = {}) {
|
|
36
|
-
const
|
|
61
|
+
const baseUrl = resolveBaseUrl()
|
|
62
|
+
const credentials = getServerCredentials(baseUrl)
|
|
37
63
|
if (!credentials) {
|
|
38
|
-
throw new Error(
|
|
64
|
+
throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
let activeCredentials = credentials
|
|
42
68
|
if (isExpired(activeCredentials.expiresAt)) {
|
|
43
|
-
activeCredentials = await refreshCredentials(activeCredentials)
|
|
69
|
+
activeCredentials = await refreshCredentials(baseUrl, activeCredentials)
|
|
44
70
|
}
|
|
45
71
|
|
|
46
|
-
let response = await fetch(`${
|
|
72
|
+
let response = await fetch(`${baseUrl}${path}`, {
|
|
47
73
|
...init,
|
|
48
74
|
headers: {
|
|
49
75
|
...(init.headers || {}),
|
|
@@ -52,8 +78,8 @@ export async function authedFetch(path: string, init: RequestInit = {}) {
|
|
|
52
78
|
})
|
|
53
79
|
|
|
54
80
|
if (response.status === 401) {
|
|
55
|
-
activeCredentials = await refreshCredentials(activeCredentials)
|
|
56
|
-
response = await fetch(`${
|
|
81
|
+
activeCredentials = await refreshCredentials(baseUrl, activeCredentials)
|
|
82
|
+
response = await fetch(`${baseUrl}${path}`, {
|
|
57
83
|
...init,
|
|
58
84
|
headers: {
|
|
59
85
|
...(init.headers || {}),
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { readFileSync, 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
|
-
import {
|
|
9
|
+
import { clearServerCredentials, getServerCredentials, saveServerCredentials } from './credentials.js'
|
|
10
10
|
import { parseDatasetFile } from './file-parser.js'
|
|
11
|
-
import {
|
|
11
|
+
import { parseDatasetReference } from './dataset-download.js'
|
|
12
|
+
import { parseGlobalFlags } from './global-flags.js'
|
|
13
|
+
import { authedFetch, getBaseUrl, setGlobalFlags } from './http.js'
|
|
12
14
|
import { LoginResponse } from './types.js'
|
|
13
15
|
|
|
14
16
|
interface Team {
|
|
@@ -50,6 +52,19 @@ interface AppSummary {
|
|
|
50
52
|
projectName: string
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
interface DatasetSummary {
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
rowCount: number
|
|
59
|
+
sourceType: string
|
|
60
|
+
createdAt: string
|
|
61
|
+
projectId: string
|
|
62
|
+
projectName: string
|
|
63
|
+
projectSlug: string
|
|
64
|
+
teamName: string
|
|
65
|
+
teamSlug: string
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
interface TeamMember {
|
|
54
69
|
id: string
|
|
55
70
|
user_id: string | null
|
|
@@ -91,16 +106,18 @@ interface TaskStatusPayload {
|
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
function printUsage() {
|
|
94
|
-
console.log(`orizu 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> --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> [--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 tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
|
|
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 tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`)
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
let cliArgs = process.argv.slice(2)
|
|
113
|
+
|
|
97
114
|
function getArg(name: string): string | null {
|
|
98
|
-
const index =
|
|
99
|
-
if (index === -1 || index + 1 >=
|
|
115
|
+
const index = cliArgs.indexOf(name)
|
|
116
|
+
if (index === -1 || index + 1 >= cliArgs.length) {
|
|
100
117
|
return null
|
|
101
118
|
}
|
|
102
119
|
|
|
103
|
-
return
|
|
120
|
+
return cliArgs[index + 1]
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
function isInteractiveTerminal() {
|
|
@@ -108,7 +125,7 @@ function isInteractiveTerminal() {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
function hasArg(name: string): boolean {
|
|
111
|
-
return
|
|
128
|
+
return cliArgs.includes(name)
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
function expandHomePath(path: string): string {
|
|
@@ -265,6 +282,16 @@ async function fetchApps(project: string): Promise<AppSummary[]> {
|
|
|
265
282
|
return data.apps
|
|
266
283
|
}
|
|
267
284
|
|
|
285
|
+
async function fetchDatasets(project: string): Promise<DatasetSummary[]> {
|
|
286
|
+
const response = await authedFetch(`/api/cli/datasets?project=${encodeURIComponent(project)}`)
|
|
287
|
+
if (!response.ok) {
|
|
288
|
+
throw new Error(`Failed to fetch datasets: ${await response.text()}`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const data = await parseJsonResponse<{ datasets: DatasetSummary[] }>(response, 'Datasets list')
|
|
292
|
+
return data.datasets
|
|
293
|
+
}
|
|
294
|
+
|
|
268
295
|
async function fetchTeamMembers(teamSlug: string): Promise<TeamMember[]> {
|
|
269
296
|
const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`)
|
|
270
297
|
if (!response.ok) {
|
|
@@ -389,6 +416,26 @@ async function selectAppIdInteractively(projectArg: string | null): Promise<{ ap
|
|
|
389
416
|
}
|
|
390
417
|
}
|
|
391
418
|
|
|
419
|
+
async function selectDatasetInteractively(projectArg: string | null): Promise<{ datasetId: string; project: string }> {
|
|
420
|
+
let project = projectArg
|
|
421
|
+
if (!project) {
|
|
422
|
+
project = await resolveProjectSlug(null)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const datasets = await fetchDatasets(project)
|
|
426
|
+
const dataset = await promptSelect(
|
|
427
|
+
`Select a dataset in ${project}`,
|
|
428
|
+
datasets,
|
|
429
|
+
item => `${item.name} (id=${item.id}, rows=${item.rowCount})`,
|
|
430
|
+
{ forcePrompt: true }
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
datasetId: dataset.id,
|
|
435
|
+
project,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
392
439
|
function printTeams(teams: Team[]) {
|
|
393
440
|
if (teams.length === 0) {
|
|
394
441
|
console.log('No teams found.')
|
|
@@ -618,11 +665,10 @@ async function login() {
|
|
|
618
665
|
}
|
|
619
666
|
|
|
620
667
|
const loginData = await parseJsonResponse<LoginResponse>(exchangeResponse, 'CLI auth exchange')
|
|
621
|
-
|
|
668
|
+
saveServerCredentials(baseUrl, {
|
|
622
669
|
accessToken: loginData.accessToken,
|
|
623
670
|
refreshToken: loginData.refreshToken,
|
|
624
671
|
expiresAt: loginData.expiresAt,
|
|
625
|
-
baseUrl,
|
|
626
672
|
})
|
|
627
673
|
|
|
628
674
|
console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`)
|
|
@@ -639,21 +685,22 @@ async function whoami() {
|
|
|
639
685
|
}
|
|
640
686
|
|
|
641
687
|
async function logout() {
|
|
642
|
-
const
|
|
688
|
+
const baseUrl = getBaseUrl()
|
|
689
|
+
const credentials = getServerCredentials(baseUrl)
|
|
643
690
|
if (!credentials) {
|
|
644
|
-
console.log(
|
|
691
|
+
console.log(`Already logged out for ${baseUrl}.`)
|
|
645
692
|
return
|
|
646
693
|
}
|
|
647
694
|
|
|
648
|
-
await fetch(`${
|
|
695
|
+
await fetch(`${baseUrl}/api/cli/auth/logout`, {
|
|
649
696
|
method: 'POST',
|
|
650
697
|
headers: {
|
|
651
698
|
Authorization: `Bearer ${credentials.accessToken}`,
|
|
652
699
|
},
|
|
653
700
|
}).catch(() => undefined)
|
|
654
701
|
|
|
655
|
-
|
|
656
|
-
console.log(
|
|
702
|
+
clearServerCredentials(baseUrl)
|
|
703
|
+
console.log(`Logged out from ${baseUrl}.`)
|
|
657
704
|
}
|
|
658
705
|
|
|
659
706
|
async function listTeams() {
|
|
@@ -783,13 +830,14 @@ function readJsonFile(pathArg: string): Record<string, unknown> {
|
|
|
783
830
|
async function createAppFromFile() {
|
|
784
831
|
const project = getArg('--project')
|
|
785
832
|
const name = getArg('--name')
|
|
833
|
+
const datasetId = getArg('--dataset')
|
|
786
834
|
const filePath = getArg('--file')
|
|
787
835
|
const inputSchemaPath = getArg('--input-schema')
|
|
788
836
|
const outputSchemaPath = getArg('--output-schema')
|
|
789
837
|
const component = getArg('--component') || undefined
|
|
790
838
|
|
|
791
|
-
if (!project || !name || !filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
792
|
-
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
|
|
839
|
+
if (!project || !name || !datasetId || !filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
840
|
+
throw new Error('Usage: orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
|
|
793
841
|
}
|
|
794
842
|
|
|
795
843
|
const sourceCode = readSourceFile(filePath)
|
|
@@ -801,6 +849,7 @@ async function createAppFromFile() {
|
|
|
801
849
|
body: JSON.stringify({
|
|
802
850
|
projectSlug: project,
|
|
803
851
|
name,
|
|
852
|
+
datasetId,
|
|
804
853
|
sourceCode,
|
|
805
854
|
componentName: component,
|
|
806
855
|
inputJsonSchema,
|
|
@@ -922,12 +971,13 @@ async function createTask() {
|
|
|
922
971
|
const datasetId = getArg('--dataset')
|
|
923
972
|
const appId = getArg('--app')
|
|
924
973
|
const title = getArg('--title')
|
|
974
|
+
const assignees = parseCommaSeparated(getArg('--assignees'))
|
|
925
975
|
const instructions = getArg('--instructions')
|
|
926
976
|
const labelsPerItemArg = getArg('--labels-per-item')
|
|
927
977
|
const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
|
|
928
978
|
|
|
929
|
-
if (!projectSlug || !datasetId || !appId || !title) {
|
|
930
|
-
throw new Error('Usage: orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> [--instructions <text>] [--labels-per-item <n>]')
|
|
979
|
+
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>]')
|
|
931
981
|
}
|
|
932
982
|
|
|
933
983
|
const response = await authedFetch('/api/cli/tasks', {
|
|
@@ -938,6 +988,7 @@ async function createTask() {
|
|
|
938
988
|
datasetId,
|
|
939
989
|
appId,
|
|
940
990
|
title,
|
|
991
|
+
memberIds: assignees,
|
|
941
992
|
instructions,
|
|
942
993
|
requiredAssignmentsPerRow: labelsPerItem,
|
|
943
994
|
}),
|
|
@@ -949,9 +1000,10 @@ async function createTask() {
|
|
|
949
1000
|
|
|
950
1001
|
const data = await parseJsonResponse<{
|
|
951
1002
|
task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
|
|
1003
|
+
assignmentsCreated: number
|
|
952
1004
|
}>(response, 'Task create')
|
|
953
1005
|
console.log(
|
|
954
|
-
`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}`
|
|
1006
|
+
`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}, assignments=${data.assignmentsCreated}`
|
|
955
1007
|
)
|
|
956
1008
|
}
|
|
957
1009
|
|
|
@@ -1121,6 +1173,55 @@ async function uploadDataset() {
|
|
|
1121
1173
|
}
|
|
1122
1174
|
}
|
|
1123
1175
|
|
|
1176
|
+
function getDatasetDownloadInput(): string | null {
|
|
1177
|
+
const fromFlag = getArg('--dataset')
|
|
1178
|
+
if (fromFlag) {
|
|
1179
|
+
return fromFlag
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const positional = cliArgs[2]
|
|
1183
|
+
if (positional && !positional.startsWith('--')) {
|
|
1184
|
+
return positional
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return null
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function downloadDataset() {
|
|
1191
|
+
const projectArg = getArg('--project')
|
|
1192
|
+
const datasetInput = getDatasetDownloadInput()
|
|
1193
|
+
const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
|
|
1194
|
+
const outPathArg = getArg('--out')
|
|
1195
|
+
|
|
1196
|
+
if (!['csv', 'json', 'jsonl'].includes(format)) {
|
|
1197
|
+
throw new Error('format must be one of: csv, json, jsonl')
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
let datasetId: string
|
|
1201
|
+
if (datasetInput) {
|
|
1202
|
+
datasetId = parseDatasetReference(datasetInput).datasetId
|
|
1203
|
+
} else {
|
|
1204
|
+
const selected = await selectDatasetInteractively(projectArg)
|
|
1205
|
+
datasetId = selected.datasetId
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const response = await authedFetch(
|
|
1209
|
+
`/api/cli/datasets/${encodeURIComponent(datasetId)}/download?format=${encodeURIComponent(format)}`
|
|
1210
|
+
)
|
|
1211
|
+
if (!response.ok) {
|
|
1212
|
+
throw new Error(`Download failed: ${await response.text()}`)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const filename = outPathArg
|
|
1216
|
+
? expandHomePath(outPathArg)
|
|
1217
|
+
: `${datasetId}.${format}`
|
|
1218
|
+
|
|
1219
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
1220
|
+
writeFileSync(filename, bytes)
|
|
1221
|
+
|
|
1222
|
+
console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1124
1225
|
async function downloadAnnotations() {
|
|
1125
1226
|
let taskId = getArg('--task')
|
|
1126
1227
|
const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
|
|
@@ -1151,8 +1252,12 @@ async function downloadAnnotations() {
|
|
|
1151
1252
|
}
|
|
1152
1253
|
|
|
1153
1254
|
async function main() {
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1255
|
+
const parsed = parseGlobalFlags(process.argv.slice(2))
|
|
1256
|
+
setGlobalFlags(parsed.flags)
|
|
1257
|
+
cliArgs = parsed.args
|
|
1258
|
+
|
|
1259
|
+
const command = cliArgs[0]
|
|
1260
|
+
const subcommand = cliArgs[1]
|
|
1156
1261
|
|
|
1157
1262
|
if (!command) {
|
|
1158
1263
|
printUsage()
|
|
@@ -1184,7 +1289,7 @@ async function main() {
|
|
|
1184
1289
|
return
|
|
1185
1290
|
}
|
|
1186
1291
|
|
|
1187
|
-
const teamsMembersAction =
|
|
1292
|
+
const teamsMembersAction = cliArgs[2]
|
|
1188
1293
|
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
|
|
1189
1294
|
await listTeamMembers()
|
|
1190
1295
|
return
|
|
@@ -1257,6 +1362,11 @@ async function main() {
|
|
|
1257
1362
|
return
|
|
1258
1363
|
}
|
|
1259
1364
|
|
|
1365
|
+
if (command === 'datasets' && subcommand === 'download') {
|
|
1366
|
+
await downloadDataset()
|
|
1367
|
+
return
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1260
1370
|
if (command === 'tasks' && subcommand === 'export') {
|
|
1261
1371
|
await downloadAnnotations()
|
|
1262
1372
|
return
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface ServerCredentials {
|
|
2
2
|
accessToken: string
|
|
3
3
|
refreshToken: string
|
|
4
4
|
expiresAt: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface StoredCredentialsV1 {
|
|
5
8
|
baseUrl: string
|
|
9
|
+
accessToken: string
|
|
10
|
+
refreshToken: string
|
|
11
|
+
expiresAt: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StoredCredentialsV2 {
|
|
15
|
+
version: 2
|
|
16
|
+
activeBaseUrl: string | null
|
|
17
|
+
servers: Record<string, ServerCredentials>
|
|
6
18
|
}
|
|
7
19
|
|
|
20
|
+
// Backward-compatible alias for legacy callers.
|
|
21
|
+
export type StoredCredentials = StoredCredentialsV1
|
|
22
|
+
|
|
8
23
|
export interface LoginResponse {
|
|
9
24
|
accessToken: string
|
|
10
25
|
refreshToken: string
|