orizu 0.0.5 → 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 +85 -17
- 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 +122 -17
- 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> --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 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());
|
|
@@ -769,6 +792,44 @@ async function uploadDataset() {
|
|
|
769
792
|
console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
|
|
770
793
|
}
|
|
771
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
|
+
}
|
|
772
833
|
async function downloadAnnotations() {
|
|
773
834
|
let taskId = getArg('--task');
|
|
774
835
|
const format = (getArg('--format') || 'jsonl');
|
|
@@ -792,8 +853,11 @@ async function downloadAnnotations() {
|
|
|
792
853
|
console.log(`Saved ${format.toUpperCase()} export to ${filename}`);
|
|
793
854
|
}
|
|
794
855
|
async function main() {
|
|
795
|
-
const
|
|
796
|
-
|
|
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];
|
|
797
861
|
if (!command) {
|
|
798
862
|
printUsage();
|
|
799
863
|
process.exit(1);
|
|
@@ -818,7 +882,7 @@ async function main() {
|
|
|
818
882
|
await createTeam();
|
|
819
883
|
return;
|
|
820
884
|
}
|
|
821
|
-
const teamsMembersAction =
|
|
885
|
+
const teamsMembersAction = cliArgs[2];
|
|
822
886
|
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
|
|
823
887
|
await listTeamMembers();
|
|
824
888
|
return;
|
|
@@ -879,6 +943,10 @@ async function main() {
|
|
|
879
943
|
await uploadDataset();
|
|
880
944
|
return;
|
|
881
945
|
}
|
|
946
|
+
if (command === 'datasets' && subcommand === 'download') {
|
|
947
|
+
await downloadDataset();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
882
950
|
if (command === 'tasks' && subcommand === 'export') {
|
|
883
951
|
await downloadAnnotations();
|
|
884
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> --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 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() {
|
|
@@ -1126,6 +1173,55 @@ async function uploadDataset() {
|
|
|
1126
1173
|
}
|
|
1127
1174
|
}
|
|
1128
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
|
+
|
|
1129
1225
|
async function downloadAnnotations() {
|
|
1130
1226
|
let taskId = getArg('--task')
|
|
1131
1227
|
const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
|
|
@@ -1156,8 +1252,12 @@ async function downloadAnnotations() {
|
|
|
1156
1252
|
}
|
|
1157
1253
|
|
|
1158
1254
|
async function main() {
|
|
1159
|
-
const
|
|
1160
|
-
|
|
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]
|
|
1161
1261
|
|
|
1162
1262
|
if (!command) {
|
|
1163
1263
|
printUsage()
|
|
@@ -1189,7 +1289,7 @@ async function main() {
|
|
|
1189
1289
|
return
|
|
1190
1290
|
}
|
|
1191
1291
|
|
|
1192
|
-
const teamsMembersAction =
|
|
1292
|
+
const teamsMembersAction = cliArgs[2]
|
|
1193
1293
|
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
|
|
1194
1294
|
await listTeamMembers()
|
|
1195
1295
|
return
|
|
@@ -1262,6 +1362,11 @@ async function main() {
|
|
|
1262
1362
|
return
|
|
1263
1363
|
}
|
|
1264
1364
|
|
|
1365
|
+
if (command === 'datasets' && subcommand === 'download') {
|
|
1366
|
+
await downloadDataset()
|
|
1367
|
+
return
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1265
1370
|
if (command === 'tasks' && subcommand === 'export') {
|
|
1266
1371
|
await downloadAnnotations()
|
|
1267
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
|