orizu 0.0.2
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 +30 -0
- package/dist/csv.js +70 -0
- package/dist/file-parser.js +64 -0
- package/dist/http.js +55 -0
- package/dist/index.js +888 -0
- package/dist/types.js +1 -0
- package/package.json +12 -0
- package/src/credentials.ts +37 -0
- package/src/csv.ts +83 -0
- package/src/file-parser.ts +83 -0
- package/src/http.ts +66 -0
- package/src/index.ts +1272 -0
- package/src/types.ts +16 -0
- package/tsconfig.json +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomBytes } from 'crypto'
|
|
3
|
+
import { basename } from 'path'
|
|
4
|
+
import { createServer } from 'http'
|
|
5
|
+
import { readFileSync, writeFileSync } from 'fs'
|
|
6
|
+
import { spawn } from 'child_process'
|
|
7
|
+
import { createInterface } from 'readline/promises'
|
|
8
|
+
import { stdin as input, stdout as output } from 'process'
|
|
9
|
+
import { clearCredentials, loadCredentials, saveCredentials } from './credentials.js'
|
|
10
|
+
import { parseDatasetFile } from './file-parser.js'
|
|
11
|
+
import { authedFetch, getBaseUrl } from './http.js'
|
|
12
|
+
import { LoginResponse } from './types.js'
|
|
13
|
+
|
|
14
|
+
interface Team {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
slug: string
|
|
18
|
+
role: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Project {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
slug: string
|
|
25
|
+
teamId: string
|
|
26
|
+
teamName: string
|
|
27
|
+
teamSlug: string
|
|
28
|
+
role: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Task {
|
|
32
|
+
id: string
|
|
33
|
+
title: string
|
|
34
|
+
status: string
|
|
35
|
+
createdAt: string
|
|
36
|
+
projectName?: string
|
|
37
|
+
projectSlug?: string
|
|
38
|
+
teamName?: string
|
|
39
|
+
teamSlug?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AppSummary {
|
|
43
|
+
id: string
|
|
44
|
+
name: string
|
|
45
|
+
currentVersionNum: number
|
|
46
|
+
createdAt: string
|
|
47
|
+
teamSlug: string
|
|
48
|
+
teamName: string
|
|
49
|
+
projectSlug: string
|
|
50
|
+
projectName: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface TeamMember {
|
|
54
|
+
id: string
|
|
55
|
+
user_id: string | null
|
|
56
|
+
email: string
|
|
57
|
+
role: string
|
|
58
|
+
joined_at: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TaskStatusPayload {
|
|
62
|
+
task: {
|
|
63
|
+
id: string
|
|
64
|
+
title: string
|
|
65
|
+
status: string
|
|
66
|
+
createdAt: string
|
|
67
|
+
teamSlug: string
|
|
68
|
+
teamName: string
|
|
69
|
+
projectSlug: string
|
|
70
|
+
projectName: string
|
|
71
|
+
datasetRowCount: number
|
|
72
|
+
requiredAssignmentsPerRow: number
|
|
73
|
+
totalRequiredAssignments: number
|
|
74
|
+
counts: {
|
|
75
|
+
completed: number
|
|
76
|
+
inProgress: number
|
|
77
|
+
pending: number
|
|
78
|
+
skipped: number
|
|
79
|
+
}
|
|
80
|
+
progressPercentage: number
|
|
81
|
+
assignees: Array<{
|
|
82
|
+
assigneeId: string
|
|
83
|
+
email: string
|
|
84
|
+
completed: number
|
|
85
|
+
inProgress: number
|
|
86
|
+
pending: number
|
|
87
|
+
skipped: number
|
|
88
|
+
total: number
|
|
89
|
+
}>
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
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>]`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getArg(name: string): string | null {
|
|
98
|
+
const index = process.argv.indexOf(name)
|
|
99
|
+
if (index === -1 || index + 1 >= process.argv.length) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return process.argv[index + 1]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isInteractiveTerminal() {
|
|
107
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasArg(name: string): boolean {
|
|
111
|
+
return process.argv.includes(name)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function expandHomePath(path: string): string {
|
|
115
|
+
if (path.startsWith('~/')) {
|
|
116
|
+
const home = process.env.HOME || ''
|
|
117
|
+
return `${home}/${path.slice(2)}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return path
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createCodeVerifier(): string {
|
|
124
|
+
return randomBytes(32).toString('base64url')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createCodeChallenge(verifier: string): string {
|
|
128
|
+
return createHash('sha256').update(verifier).digest('base64url')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function openInBrowser(url: string) {
|
|
132
|
+
const platform = process.platform
|
|
133
|
+
if (platform === 'darwin') {
|
|
134
|
+
spawn('open', [url], {
|
|
135
|
+
detached: true,
|
|
136
|
+
stdio: 'ignore',
|
|
137
|
+
}).unref()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (platform === 'win32') {
|
|
142
|
+
spawn('cmd', ['/c', 'start', '', url], {
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: 'ignore',
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
}).unref()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
spawn('xdg-open', [url], {
|
|
151
|
+
detached: true,
|
|
152
|
+
stdio: 'ignore',
|
|
153
|
+
}).unref()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function formatTerminalLink(url: string): string {
|
|
157
|
+
if (!isInteractiveTerminal()) {
|
|
158
|
+
return url
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function parseJsonResponse<T>(response: Response, context: string): Promise<T> {
|
|
165
|
+
const contentType = response.headers.get('content-type') || ''
|
|
166
|
+
const rawBody = await response.text()
|
|
167
|
+
|
|
168
|
+
if (!contentType.includes('application/json')) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${context} returned non-JSON response (status ${response.status}). ` +
|
|
171
|
+
`Body preview: ${rawBody.slice(0, 180)}`
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(rawBody) as T
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${context} returned invalid JSON (status ${response.status}). ` +
|
|
180
|
+
`Body preview: ${rawBody.slice(0, 180)}`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function promptSelect<T>(
|
|
186
|
+
title: string,
|
|
187
|
+
items: T[],
|
|
188
|
+
label: (item: T, index: number) => string,
|
|
189
|
+
options?: { forcePrompt?: boolean }
|
|
190
|
+
): Promise<T> {
|
|
191
|
+
if (items.length === 0) {
|
|
192
|
+
throw new Error(`No options available for ${title.toLowerCase()}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!isInteractiveTerminal()) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`${title} selection requires interactive terminal. Provide flags explicitly instead.`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (items.length === 1 && !options?.forcePrompt) {
|
|
202
|
+
return items[0]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(`\n${title}`)
|
|
206
|
+
items.forEach((item, index) => {
|
|
207
|
+
console.log(` ${index + 1}. ${label(item, index)}`)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const rl = createInterface({ input, output })
|
|
211
|
+
try {
|
|
212
|
+
while (true) {
|
|
213
|
+
const answer = (await rl.question('Choose a number: ')).trim()
|
|
214
|
+
const chosenIndex = Number(answer)
|
|
215
|
+
if (Number.isInteger(chosenIndex) && chosenIndex >= 1 && chosenIndex <= items.length) {
|
|
216
|
+
return items[chosenIndex - 1]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log('Invalid selection. Enter a valid number from the list.')
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
rl.close()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function fetchTeams(): Promise<Team[]> {
|
|
227
|
+
const response = await authedFetch('/api/cli/teams')
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
throw new Error(`Failed to fetch teams: ${await response.text()}`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const data = await parseJsonResponse<{ teams: Team[] }>(response, 'Teams list')
|
|
233
|
+
return data.teams
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function fetchProjects(teamSlug?: string): Promise<Project[]> {
|
|
237
|
+
const query = teamSlug ? `?teamSlug=${encodeURIComponent(teamSlug)}` : ''
|
|
238
|
+
const response = await authedFetch(`/api/cli/projects${query}`)
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
throw new Error(`Failed to fetch projects: ${await response.text()}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const data = await parseJsonResponse<{ projects: Project[] }>(response, 'Projects list')
|
|
244
|
+
return data.projects
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function fetchTasks(project?: string): Promise<Task[]> {
|
|
248
|
+
const query = project ? `?project=${encodeURIComponent(project)}` : ''
|
|
249
|
+
const response = await authedFetch(`/api/cli/tasks${query}`)
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
throw new Error(`Failed to fetch tasks: ${await response.text()}`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const data = await parseJsonResponse<{ tasks: Task[] }>(response, 'Tasks list')
|
|
255
|
+
return data.tasks
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function fetchApps(project: string): Promise<AppSummary[]> {
|
|
259
|
+
const response = await authedFetch(`/api/cli/apps?project=${encodeURIComponent(project)}`)
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error(`Failed to fetch apps: ${await response.text()}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const data = await parseJsonResponse<{ apps: AppSummary[] }>(response, 'Apps list')
|
|
265
|
+
return data.apps
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function fetchTeamMembers(teamSlug: string): Promise<TeamMember[]> {
|
|
269
|
+
const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`)
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw new Error(`Failed to fetch team members: ${await response.text()}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const data = await parseJsonResponse<{ members: TeamMember[] }>(response, 'Team members list')
|
|
275
|
+
return data.members
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function resolveProjectSlug(projectArg: string | null): Promise<string> {
|
|
279
|
+
const teams = await fetchTeams()
|
|
280
|
+
|
|
281
|
+
if (teams.length === 0) {
|
|
282
|
+
throw new Error('No accessible teams found for this user.')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!projectArg) {
|
|
286
|
+
const team = await promptSelect(
|
|
287
|
+
'Select a team',
|
|
288
|
+
teams,
|
|
289
|
+
teamOption => `${teamOption.name} (${teamOption.slug})`,
|
|
290
|
+
{ forcePrompt: true }
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
const projects = await fetchProjects(team.slug)
|
|
294
|
+
const project = await promptSelect(
|
|
295
|
+
`Select a project in ${team.slug}`,
|
|
296
|
+
projects,
|
|
297
|
+
projectOption => `${projectOption.name} (${projectOption.teamSlug}/${projectOption.slug})`,
|
|
298
|
+
{ forcePrompt: true }
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return `${project.teamSlug}/${project.slug}`
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const segments = projectArg.split('/')
|
|
305
|
+
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
|
306
|
+
throw new Error('Project must be in format teamSlug/projectSlug')
|
|
307
|
+
}
|
|
308
|
+
const [teamSlug, projectSlug] = segments
|
|
309
|
+
|
|
310
|
+
const matchedTeam = teams.find(team => team.slug === teamSlug)
|
|
311
|
+
if (!matchedTeam) {
|
|
312
|
+
console.error(`Team '${teamSlug}' not found in your accessible teams.`)
|
|
313
|
+
const selectedTeam = await promptSelect(
|
|
314
|
+
'Select a team',
|
|
315
|
+
teams,
|
|
316
|
+
team => `${team.name} (${team.slug})`
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const projects = await fetchProjects(selectedTeam.slug)
|
|
320
|
+
const selectedProject = await promptSelect(
|
|
321
|
+
`Select a project in ${selectedTeam.slug}`,
|
|
322
|
+
projects,
|
|
323
|
+
project => `${project.name} (${project.teamSlug}/${project.slug})`
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return `${selectedProject.teamSlug}/${selectedProject.slug}`
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const projects = await fetchProjects(teamSlug)
|
|
330
|
+
const matchedProject = projects.find(project => project.slug === projectSlug)
|
|
331
|
+
|
|
332
|
+
if (!matchedProject) {
|
|
333
|
+
console.error(`Project '${projectSlug}' not found in team '${teamSlug}'.`)
|
|
334
|
+
const selectedProject = await promptSelect(
|
|
335
|
+
`Select a project in ${teamSlug}`,
|
|
336
|
+
projects,
|
|
337
|
+
project => `${project.name} (${project.teamSlug}/${project.slug})`
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return `${selectedProject.teamSlug}/${selectedProject.slug}`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return `${teamSlug}/${projectSlug}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function selectTaskIdInteractively(): Promise<string> {
|
|
347
|
+
const team = await promptSelect(
|
|
348
|
+
'Select a team',
|
|
349
|
+
await fetchTeams(),
|
|
350
|
+
item => `${item.name} (${item.slug})`,
|
|
351
|
+
{ forcePrompt: true }
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const project = await promptSelect(
|
|
355
|
+
`Select a project in ${team.slug}`,
|
|
356
|
+
await fetchProjects(team.slug),
|
|
357
|
+
item => `${item.name} (${item.teamSlug}/${item.slug})`,
|
|
358
|
+
{ forcePrompt: true }
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
const tasks = await fetchTasks(`${project.teamSlug}/${project.slug}`)
|
|
362
|
+
const task = await promptSelect(
|
|
363
|
+
`Select a task in ${project.teamSlug}/${project.slug}`,
|
|
364
|
+
tasks,
|
|
365
|
+
item => `${item.title} [${item.status}] (${item.id})`,
|
|
366
|
+
{ forcePrompt: true }
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return task.id
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function selectAppIdInteractively(projectArg: string | null): Promise<{ appId: string; project: string }> {
|
|
373
|
+
let project = projectArg
|
|
374
|
+
if (!project) {
|
|
375
|
+
project = await resolveProjectSlug(null)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const apps = await fetchApps(project)
|
|
379
|
+
const app = await promptSelect(
|
|
380
|
+
`Select an app in ${project}`,
|
|
381
|
+
apps,
|
|
382
|
+
item => `${item.name} (id=${item.id}, v${item.currentVersionNum})`,
|
|
383
|
+
{ forcePrompt: true }
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
appId: app.id,
|
|
388
|
+
project,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function printTeams(teams: Team[]) {
|
|
393
|
+
if (teams.length === 0) {
|
|
394
|
+
console.log('No teams found.')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const rows = teams.map(team => ({
|
|
399
|
+
slug: team.slug,
|
|
400
|
+
name: team.name || '-',
|
|
401
|
+
role: team.role || '-',
|
|
402
|
+
}))
|
|
403
|
+
|
|
404
|
+
const slugWidth = Math.max('TEAM SLUG'.length, ...rows.map(row => row.slug.length))
|
|
405
|
+
const nameWidth = Math.max('TEAM NAME'.length, ...rows.map(row => row.name.length))
|
|
406
|
+
const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
|
|
407
|
+
|
|
408
|
+
console.log(
|
|
409
|
+
`${'TEAM SLUG'.padEnd(slugWidth)} ${'TEAM NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
|
|
410
|
+
)
|
|
411
|
+
console.log(
|
|
412
|
+
`${'-'.repeat(slugWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
rows.forEach(row => {
|
|
416
|
+
console.log(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function printProjects(projects: Project[]) {
|
|
421
|
+
if (projects.length === 0) {
|
|
422
|
+
console.log('No projects found.')
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const rows = projects.map(project => ({
|
|
427
|
+
project: `${project.teamSlug}/${project.slug}`,
|
|
428
|
+
name: project.name || '-',
|
|
429
|
+
role: project.role || '-',
|
|
430
|
+
}))
|
|
431
|
+
|
|
432
|
+
const projectWidth = Math.max('TEAM/PROJECT'.length, ...rows.map(row => row.project.length))
|
|
433
|
+
const nameWidth = Math.max('PROJECT NAME'.length, ...rows.map(row => row.name.length))
|
|
434
|
+
const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
|
|
435
|
+
|
|
436
|
+
console.log(
|
|
437
|
+
`${'TEAM/PROJECT'.padEnd(projectWidth)} ${'PROJECT NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`
|
|
438
|
+
)
|
|
439
|
+
console.log(
|
|
440
|
+
`${'-'.repeat(projectWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
rows.forEach(row => {
|
|
444
|
+
console.log(
|
|
445
|
+
`${row.project.padEnd(projectWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`
|
|
446
|
+
)
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function printTasks(tasks: Task[]) {
|
|
451
|
+
if (tasks.length === 0) {
|
|
452
|
+
console.log('No tasks found.')
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const rows = tasks.map(task => ({
|
|
457
|
+
id: task.id,
|
|
458
|
+
name: task.title || '-',
|
|
459
|
+
status: task.status || '-',
|
|
460
|
+
project: task.teamSlug && task.projectSlug
|
|
461
|
+
? `${task.teamSlug}/${task.projectSlug}`
|
|
462
|
+
: 'unknown-project',
|
|
463
|
+
}))
|
|
464
|
+
|
|
465
|
+
const idWidth = Math.max('TASK ID'.length, ...rows.map(row => row.id.length))
|
|
466
|
+
const nameWidth = Math.max('TASK NAME'.length, ...rows.map(row => row.name.length))
|
|
467
|
+
const statusWidth = Math.max('STATUS'.length, ...rows.map(row => row.status.length))
|
|
468
|
+
|
|
469
|
+
console.log(
|
|
470
|
+
`${'TASK ID'.padEnd(idWidth)} ${'TASK NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} TEAM/PROJECT`
|
|
471
|
+
)
|
|
472
|
+
console.log(
|
|
473
|
+
`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(statusWidth)} ------------`
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
rows.forEach(row => {
|
|
477
|
+
console.log(
|
|
478
|
+
`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.project}`
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function printApps(apps: AppSummary[]) {
|
|
484
|
+
if (apps.length === 0) {
|
|
485
|
+
console.log('No apps found.')
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const rows = apps.map(app => ({
|
|
490
|
+
id: app.id,
|
|
491
|
+
name: app.name || '-',
|
|
492
|
+
version: `v${app.currentVersionNum || 1}`,
|
|
493
|
+
}))
|
|
494
|
+
|
|
495
|
+
const idWidth = Math.max('APP ID'.length, ...rows.map(row => row.id.length))
|
|
496
|
+
const nameWidth = Math.max('APP NAME'.length, ...rows.map(row => row.name.length))
|
|
497
|
+
const versionWidth = Math.max('VERSION'.length, ...rows.map(row => row.version.length))
|
|
498
|
+
|
|
499
|
+
console.log(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`)
|
|
500
|
+
console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`)
|
|
501
|
+
|
|
502
|
+
rows.forEach(row => {
|
|
503
|
+
console.log(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`)
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function printTeamMembers(members: TeamMember[]) {
|
|
508
|
+
if (members.length === 0) {
|
|
509
|
+
console.log('No team members found.')
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const rows = members.map(member => ({
|
|
514
|
+
id: member.id,
|
|
515
|
+
email: member.email || '-',
|
|
516
|
+
role: member.role || '-',
|
|
517
|
+
}))
|
|
518
|
+
|
|
519
|
+
const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length))
|
|
520
|
+
const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length))
|
|
521
|
+
const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length))
|
|
522
|
+
|
|
523
|
+
console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`)
|
|
524
|
+
console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`)
|
|
525
|
+
rows.forEach(row => {
|
|
526
|
+
console.log(`${row.id.padEnd(idWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`)
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function printTaskStatusSummary(data: TaskStatusPayload) {
|
|
531
|
+
const task = data.task
|
|
532
|
+
console.log(`Task: ${task.title} (${task.id})`)
|
|
533
|
+
console.log(`Status: ${task.status}`)
|
|
534
|
+
console.log(`Project: ${task.teamSlug}/${task.projectSlug}`)
|
|
535
|
+
console.log(`Progress: ${task.progressPercentage}%`)
|
|
536
|
+
console.log(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`)
|
|
537
|
+
console.log(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`)
|
|
538
|
+
|
|
539
|
+
if (task.assignees.length > 0) {
|
|
540
|
+
console.log('\nAssignees')
|
|
541
|
+
task.assignees.forEach(assignee => {
|
|
542
|
+
console.log(
|
|
543
|
+
` ${assignee.email}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`
|
|
544
|
+
)
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function login() {
|
|
550
|
+
const baseUrl = getBaseUrl()
|
|
551
|
+
const codeVerifier = createCodeVerifier()
|
|
552
|
+
const codeChallenge = createCodeChallenge(codeVerifier)
|
|
553
|
+
|
|
554
|
+
const callbackCode = await new Promise<string>((resolve, reject) => {
|
|
555
|
+
const server = createServer((request, response) => {
|
|
556
|
+
try {
|
|
557
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1:43123')
|
|
558
|
+
const code = url.searchParams.get('code')
|
|
559
|
+
|
|
560
|
+
if (!code) {
|
|
561
|
+
response.statusCode = 400
|
|
562
|
+
response.end('Missing code')
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
response.statusCode = 200
|
|
567
|
+
response.setHeader('content-type', 'text/html')
|
|
568
|
+
response.end('<html><body><h3>CLI login complete. You can close this tab.</h3></body></html>')
|
|
569
|
+
|
|
570
|
+
server.close()
|
|
571
|
+
resolve(code)
|
|
572
|
+
} catch (error) {
|
|
573
|
+
server.close()
|
|
574
|
+
reject(error)
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
server.on('error', reject)
|
|
579
|
+
|
|
580
|
+
server.listen(43123, '127.0.0.1', async () => {
|
|
581
|
+
try {
|
|
582
|
+
const redirectUri = 'http://127.0.0.1:43123/callback'
|
|
583
|
+
const response = await fetch(`${baseUrl}/api/cli/auth/start`, {
|
|
584
|
+
method: 'POST',
|
|
585
|
+
headers: { 'Content-Type': 'application/json' },
|
|
586
|
+
body: JSON.stringify({ codeChallenge, redirectUri }),
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
if (!response.ok) {
|
|
590
|
+
const text = await response.text()
|
|
591
|
+
server.close()
|
|
592
|
+
reject(new Error(`Failed to start login: ${text}`))
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const { authorizeUrl } = await parseJsonResponse<{ authorizeUrl: string }>(
|
|
597
|
+
response,
|
|
598
|
+
'CLI auth start'
|
|
599
|
+
)
|
|
600
|
+
console.log(`Opening browser for login: ${authorizeUrl}`)
|
|
601
|
+
openInBrowser(authorizeUrl)
|
|
602
|
+
} catch (error) {
|
|
603
|
+
server.close()
|
|
604
|
+
reject(error)
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
const exchangeResponse = await fetch(`${baseUrl}/api/cli/auth/exchange`, {
|
|
610
|
+
method: 'POST',
|
|
611
|
+
headers: { 'Content-Type': 'application/json' },
|
|
612
|
+
body: JSON.stringify({ code: callbackCode, codeVerifier }),
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
if (!exchangeResponse.ok) {
|
|
616
|
+
const text = await exchangeResponse.text()
|
|
617
|
+
throw new Error(`Failed to exchange auth code: ${text}`)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const loginData = await parseJsonResponse<LoginResponse>(exchangeResponse, 'CLI auth exchange')
|
|
621
|
+
saveCredentials({
|
|
622
|
+
accessToken: loginData.accessToken,
|
|
623
|
+
refreshToken: loginData.refreshToken,
|
|
624
|
+
expiresAt: loginData.expiresAt,
|
|
625
|
+
baseUrl,
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function whoami() {
|
|
632
|
+
const response = await authedFetch('/api/cli/auth/whoami')
|
|
633
|
+
if (!response.ok) {
|
|
634
|
+
throw new Error(`whoami failed: ${await response.text()}`)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const data = await response.json() as { user: { id: string; email: string | null } }
|
|
638
|
+
console.log(data.user.email ?? data.user.id)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function logout() {
|
|
642
|
+
const credentials = loadCredentials()
|
|
643
|
+
if (!credentials) {
|
|
644
|
+
console.log('Already logged out.')
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
await fetch(`${credentials.baseUrl}/api/cli/auth/logout`, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: {
|
|
651
|
+
Authorization: `Bearer ${credentials.accessToken}`,
|
|
652
|
+
},
|
|
653
|
+
}).catch(() => undefined)
|
|
654
|
+
|
|
655
|
+
clearCredentials()
|
|
656
|
+
console.log('Logged out.')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function listTeams() {
|
|
660
|
+
printTeams(await fetchTeams())
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function resolveTeamSlug(teamSlugArg: string | null): Promise<string> {
|
|
664
|
+
if (teamSlugArg) {
|
|
665
|
+
return teamSlugArg
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const team = await promptSelect(
|
|
669
|
+
'Select a team',
|
|
670
|
+
await fetchTeams(),
|
|
671
|
+
item => `${item.name} (${item.slug})`,
|
|
672
|
+
{ forcePrompt: true }
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
return team.slug
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function createTeam() {
|
|
679
|
+
let name = getArg('--name')
|
|
680
|
+
|
|
681
|
+
if (!name && isInteractiveTerminal()) {
|
|
682
|
+
const rl = createInterface({ input, output })
|
|
683
|
+
try {
|
|
684
|
+
name = (await rl.question('Team name: ')).trim()
|
|
685
|
+
} finally {
|
|
686
|
+
rl.close()
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (!name) {
|
|
691
|
+
throw new Error('Usage: orizu teams create --name <name>')
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const response = await authedFetch('/api/cli/teams', {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
headers: { 'Content-Type': 'application/json' },
|
|
697
|
+
body: JSON.stringify({ name }),
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
if (!response.ok) {
|
|
701
|
+
throw new Error(`Failed to create team: ${await response.text()}`)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const data = await parseJsonResponse<{ team: Team }>(response, 'Team create')
|
|
705
|
+
console.log(`Created team: ${data.team.name} (${data.team.slug})`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function listProjects() {
|
|
709
|
+
const teamSlug = getArg('--team')
|
|
710
|
+
printProjects(await fetchProjects(teamSlug || undefined))
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function createProject() {
|
|
714
|
+
const name = getArg('--name')
|
|
715
|
+
let teamSlug = getArg('--team')
|
|
716
|
+
|
|
717
|
+
if (!name) {
|
|
718
|
+
throw new Error('Usage: orizu projects create --name <name> [--team <teamSlug>]')
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (!teamSlug) {
|
|
722
|
+
const team = await promptSelect(
|
|
723
|
+
'Select a team',
|
|
724
|
+
await fetchTeams(),
|
|
725
|
+
item => `${item.name} (${item.slug})`,
|
|
726
|
+
{ forcePrompt: true }
|
|
727
|
+
)
|
|
728
|
+
teamSlug = team.slug
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const response = await authedFetch('/api/cli/projects', {
|
|
732
|
+
method: 'POST',
|
|
733
|
+
headers: { 'Content-Type': 'application/json' },
|
|
734
|
+
body: JSON.stringify({ teamSlug, name }),
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
if (!response.ok) {
|
|
738
|
+
throw new Error(`Failed to create project: ${await response.text()}`)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const data = await parseJsonResponse<{
|
|
742
|
+
project: { id: string; name: string; slug: string; teamSlug: string }
|
|
743
|
+
}>(response, 'Project create')
|
|
744
|
+
console.log(`Created project ${data.project.teamSlug}/${data.project.slug}`)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function listTasks() {
|
|
748
|
+
const project = getArg('--project')
|
|
749
|
+
printTasks(await fetchTasks(project || undefined))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function listApps() {
|
|
753
|
+
const project = getArg('--project') || await resolveProjectSlug(null)
|
|
754
|
+
printApps(await fetchApps(project))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function readSourceFile(pathArg: string): string {
|
|
758
|
+
const expandedPath = expandHomePath(pathArg)
|
|
759
|
+
try {
|
|
760
|
+
return readFileSync(expandedPath, 'utf-8')
|
|
761
|
+
} catch (error: any) {
|
|
762
|
+
if (error?.code === 'ENOENT') {
|
|
763
|
+
throw new Error(`File not found: ${expandedPath}`)
|
|
764
|
+
}
|
|
765
|
+
throw new Error(`Failed to read file '${expandedPath}': ${error?.message || String(error)}`)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function readJsonFile(pathArg: string): Record<string, unknown> {
|
|
770
|
+
const raw = readSourceFile(pathArg)
|
|
771
|
+
try {
|
|
772
|
+
const parsed = JSON.parse(raw) as unknown
|
|
773
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
774
|
+
throw new Error('JSON root must be an object')
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return parsed as Record<string, unknown>
|
|
778
|
+
} catch (error: any) {
|
|
779
|
+
throw new Error(`Invalid JSON file '${pathArg}': ${error?.message || String(error)}`)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function createAppFromFile() {
|
|
784
|
+
const project = getArg('--project')
|
|
785
|
+
const name = getArg('--name')
|
|
786
|
+
const filePath = getArg('--file')
|
|
787
|
+
const inputSchemaPath = getArg('--input-schema')
|
|
788
|
+
const outputSchemaPath = getArg('--output-schema')
|
|
789
|
+
const component = getArg('--component') || undefined
|
|
790
|
+
|
|
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>]')
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const sourceCode = readSourceFile(filePath)
|
|
796
|
+
const inputJsonSchema = readJsonFile(inputSchemaPath)
|
|
797
|
+
const outputJsonSchema = readJsonFile(outputSchemaPath)
|
|
798
|
+
const response = await authedFetch('/api/cli/apps/create-from-file', {
|
|
799
|
+
method: 'POST',
|
|
800
|
+
headers: { 'Content-Type': 'application/json' },
|
|
801
|
+
body: JSON.stringify({
|
|
802
|
+
projectSlug: project,
|
|
803
|
+
name,
|
|
804
|
+
sourceCode,
|
|
805
|
+
componentName: component,
|
|
806
|
+
inputJsonSchema,
|
|
807
|
+
outputJsonSchema,
|
|
808
|
+
}),
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
if (!response.ok) {
|
|
812
|
+
throw new Error(`Failed to create app: ${await response.text()}`)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const data = await parseJsonResponse<{
|
|
816
|
+
app: { id: string; name: string; versionNum: number; componentName?: string }
|
|
817
|
+
warnings?: string[]
|
|
818
|
+
}>(response, 'App create')
|
|
819
|
+
console.log(`Created app ${data.app.name} (${data.app.id}) v${data.app.versionNum}`)
|
|
820
|
+
if (data.warnings?.length) {
|
|
821
|
+
console.log(`Warnings: ${data.warnings.join('; ')}`)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function updateAppFromFile() {
|
|
826
|
+
const filePath = getArg('--file')
|
|
827
|
+
const inputSchemaPath = getArg('--input-schema')
|
|
828
|
+
const outputSchemaPath = getArg('--output-schema')
|
|
829
|
+
const component = getArg('--component') || undefined
|
|
830
|
+
let appId = getArg('--app')
|
|
831
|
+
const project = getArg('--project')
|
|
832
|
+
|
|
833
|
+
if (!filePath || !inputSchemaPath || !outputSchemaPath) {
|
|
834
|
+
throw new Error('Usage: orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]')
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!appId) {
|
|
838
|
+
const selected = await selectAppIdInteractively(project)
|
|
839
|
+
appId = selected.appId
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const sourceCode = readSourceFile(filePath)
|
|
843
|
+
const inputJsonSchema = readJsonFile(inputSchemaPath)
|
|
844
|
+
const outputJsonSchema = readJsonFile(outputSchemaPath)
|
|
845
|
+
const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/update-from-file`, {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
headers: { 'Content-Type': 'application/json' },
|
|
848
|
+
body: JSON.stringify({
|
|
849
|
+
sourceCode,
|
|
850
|
+
componentName: component,
|
|
851
|
+
inputJsonSchema,
|
|
852
|
+
outputJsonSchema,
|
|
853
|
+
}),
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
if (!response.ok) {
|
|
857
|
+
throw new Error(`Failed to update app: ${await response.text()}`)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const data = await parseJsonResponse<{
|
|
861
|
+
app: { id: string; name: string; versionNum: number; componentName?: string }
|
|
862
|
+
warnings?: string[]
|
|
863
|
+
}>(response, 'App update')
|
|
864
|
+
console.log(`Updated app ${data.app.name} (${data.app.id}) to v${data.app.versionNum}`)
|
|
865
|
+
if (data.warnings?.length) {
|
|
866
|
+
console.log(`Warnings: ${data.warnings.join('; ')}`)
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function linkAppDataset() {
|
|
871
|
+
const datasetId = getArg('--dataset')
|
|
872
|
+
const project = getArg('--project')
|
|
873
|
+
let appId = getArg('--app')
|
|
874
|
+
const versionArg = getArg('--version')
|
|
875
|
+
const versionNum = versionArg ? Number(versionArg) : undefined
|
|
876
|
+
|
|
877
|
+
if (!datasetId) {
|
|
878
|
+
throw new Error('Usage: orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]')
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!appId) {
|
|
882
|
+
const selected = await selectAppIdInteractively(project)
|
|
883
|
+
appId = selected.appId
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const response = await authedFetch(`/api/cli/apps/${encodeURIComponent(appId)}/link-dataset`, {
|
|
887
|
+
method: 'POST',
|
|
888
|
+
headers: { 'Content-Type': 'application/json' },
|
|
889
|
+
body: JSON.stringify({
|
|
890
|
+
datasetId,
|
|
891
|
+
versionNum,
|
|
892
|
+
}),
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
if (!response.ok) {
|
|
896
|
+
throw new Error(`Failed to link dataset: ${await response.text()}`)
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const data = await parseJsonResponse<{
|
|
900
|
+
app: { id: string; name: string }
|
|
901
|
+
linkedDataset: { id: string; name: string }
|
|
902
|
+
versionNum: number
|
|
903
|
+
}>(response, 'App link dataset')
|
|
904
|
+
|
|
905
|
+
console.log(
|
|
906
|
+
`Linked dataset ${data.linkedDataset.name} (${data.linkedDataset.id}) to app ${data.app.name} (${data.app.id}) version ${data.versionNum}`
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function parseCommaSeparated(value: string | null): string[] {
|
|
911
|
+
if (!value) {
|
|
912
|
+
return []
|
|
913
|
+
}
|
|
914
|
+
return value
|
|
915
|
+
.split(',')
|
|
916
|
+
.map(item => item.trim())
|
|
917
|
+
.filter(Boolean)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function createTask() {
|
|
921
|
+
const projectSlug = getArg('--project')
|
|
922
|
+
const datasetId = getArg('--dataset')
|
|
923
|
+
const appId = getArg('--app')
|
|
924
|
+
const title = getArg('--title')
|
|
925
|
+
const instructions = getArg('--instructions')
|
|
926
|
+
const labelsPerItemArg = getArg('--labels-per-item')
|
|
927
|
+
const labelsPerItem = labelsPerItemArg ? Number(labelsPerItemArg) : 1
|
|
928
|
+
|
|
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>]')
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const response = await authedFetch('/api/cli/tasks', {
|
|
934
|
+
method: 'POST',
|
|
935
|
+
headers: { 'Content-Type': 'application/json' },
|
|
936
|
+
body: JSON.stringify({
|
|
937
|
+
projectSlug,
|
|
938
|
+
datasetId,
|
|
939
|
+
appId,
|
|
940
|
+
title,
|
|
941
|
+
instructions,
|
|
942
|
+
requiredAssignmentsPerRow: labelsPerItem,
|
|
943
|
+
}),
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
if (!response.ok) {
|
|
947
|
+
throw new Error(`Failed to create task: ${await response.text()}`)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const data = await parseJsonResponse<{
|
|
951
|
+
task: { id: string; title: string; status: string; requiredAssignmentsPerRow: number }
|
|
952
|
+
}>(response, 'Task create')
|
|
953
|
+
console.log(
|
|
954
|
+
`Created task ${data.task.title} (${data.task.id}) [${data.task.status}], labels/item=${data.task.requiredAssignmentsPerRow}`
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function assignTask() {
|
|
959
|
+
const taskId = getArg('--task')
|
|
960
|
+
const assignees = parseCommaSeparated(getArg('--assignees'))
|
|
961
|
+
|
|
962
|
+
if (!taskId || assignees.length === 0) {
|
|
963
|
+
throw new Error('Usage: orizu tasks assign --task <taskId> --assignees <userId1,userId2>')
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/assign`, {
|
|
967
|
+
method: 'POST',
|
|
968
|
+
headers: { 'Content-Type': 'application/json' },
|
|
969
|
+
body: JSON.stringify({ memberIds: assignees }),
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
if (!response.ok) {
|
|
973
|
+
throw new Error(`Failed to assign task: ${await response.text()}`)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const data = await parseJsonResponse<{ assignmentsCreated: number }>(response, 'Task assign')
|
|
977
|
+
console.log(`Created ${data.assignmentsCreated} assignments.`)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function taskStatus() {
|
|
981
|
+
const taskId = getArg('--task')
|
|
982
|
+
if (!taskId) {
|
|
983
|
+
throw new Error('Usage: orizu tasks status --task <taskId> [--json]')
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/status`)
|
|
987
|
+
if (!response.ok) {
|
|
988
|
+
throw new Error(`Failed to fetch task status: ${await response.text()}`)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const data = await parseJsonResponse<TaskStatusPayload>(response, 'Task status')
|
|
992
|
+
if (hasArg('--json')) {
|
|
993
|
+
console.log(JSON.stringify(data, null, 2))
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
printTaskStatusSummary(data)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function listTeamMembers() {
|
|
1001
|
+
const teamSlug = await resolveTeamSlug(getArg('--team'))
|
|
1002
|
+
|
|
1003
|
+
printTeamMembers(await fetchTeamMembers(teamSlug))
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function addTeamMember() {
|
|
1007
|
+
const teamSlug = await resolveTeamSlug(getArg('--team'))
|
|
1008
|
+
const email = getArg('--email')
|
|
1009
|
+
if (!email) {
|
|
1010
|
+
throw new Error('Usage: orizu teams members add --email <email> [--team <teamSlug>]')
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const response = await authedFetch(`/api/cli/teams/${encodeURIComponent(teamSlug)}/members`, {
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1016
|
+
body: JSON.stringify({ email }),
|
|
1017
|
+
})
|
|
1018
|
+
if (!response.ok) {
|
|
1019
|
+
throw new Error(`Failed to add team member: ${await response.text()}`)
|
|
1020
|
+
}
|
|
1021
|
+
const data = await parseJsonResponse<{ member: TeamMember }>(response, 'Team member add')
|
|
1022
|
+
console.log(`Added team member ${data.member.email} (${data.member.id})`)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async function removeTeamMember() {
|
|
1026
|
+
const teamSlug = await resolveTeamSlug(getArg('--team'))
|
|
1027
|
+
const email = getArg('--email')
|
|
1028
|
+
if (!email) {
|
|
1029
|
+
throw new Error('Usage: orizu teams members remove --email <email> [--team <teamSlug>]')
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const members = await fetchTeamMembers(teamSlug)
|
|
1033
|
+
const member = members.find(item => item.email.toLowerCase() === email.toLowerCase())
|
|
1034
|
+
if (!member) {
|
|
1035
|
+
throw new Error(`No member found with email '${email}' in team '${teamSlug}'`)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const response = await authedFetch(
|
|
1039
|
+
`/api/cli/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(member.id)}`,
|
|
1040
|
+
{ method: 'DELETE' }
|
|
1041
|
+
)
|
|
1042
|
+
if (!response.ok) {
|
|
1043
|
+
throw new Error(`Failed to remove team member: ${await response.text()}`)
|
|
1044
|
+
}
|
|
1045
|
+
console.log(`Removed team member ${member.email}`)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function changeTeamMemberRole() {
|
|
1049
|
+
const teamSlug = getArg('--team')
|
|
1050
|
+
const email = getArg('--email')
|
|
1051
|
+
const role = getArg('--role')
|
|
1052
|
+
if (!teamSlug || !email || !role) {
|
|
1053
|
+
throw new Error('Usage: orizu teams members role --team <teamSlug> --email <email> --role <admin|member>')
|
|
1054
|
+
}
|
|
1055
|
+
if (!['admin', 'member'].includes(role)) {
|
|
1056
|
+
throw new Error('role must be one of: admin, member')
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const members = await fetchTeamMembers(teamSlug)
|
|
1060
|
+
const member = members.find(item => item.email.toLowerCase() === email.toLowerCase())
|
|
1061
|
+
if (!member) {
|
|
1062
|
+
throw new Error(`No member found with email '${email}' in team '${teamSlug}'`)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const response = await authedFetch(
|
|
1066
|
+
`/api/cli/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(member.id)}`,
|
|
1067
|
+
{
|
|
1068
|
+
method: 'PATCH',
|
|
1069
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1070
|
+
body: JSON.stringify({ role }),
|
|
1071
|
+
}
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
if (!response.ok) {
|
|
1075
|
+
throw new Error(`Failed to update member role: ${await response.text()}`)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
console.log(`Updated ${member.email} role to ${role}`)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function uploadDataset() {
|
|
1082
|
+
const projectArg = getArg('--project')
|
|
1083
|
+
const fileArg = getArg('--file')
|
|
1084
|
+
const name = getArg('--name')
|
|
1085
|
+
|
|
1086
|
+
if (!fileArg) {
|
|
1087
|
+
throw new Error('Usage: orizu datasets upload --file <path> [--project <team/project>] [--name <name>]')
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const file = expandHomePath(fileArg)
|
|
1091
|
+
const project = await resolveProjectSlug(projectArg)
|
|
1092
|
+
|
|
1093
|
+
const { rows, sourceType } = parseDatasetFile(file)
|
|
1094
|
+
const datasetName = name || basename(file)
|
|
1095
|
+
|
|
1096
|
+
const response = await authedFetch('/api/cli/datasets/upload', {
|
|
1097
|
+
method: 'POST',
|
|
1098
|
+
headers: {
|
|
1099
|
+
'Content-Type': 'application/json',
|
|
1100
|
+
},
|
|
1101
|
+
body: JSON.stringify({
|
|
1102
|
+
projectSlug: project,
|
|
1103
|
+
name: datasetName,
|
|
1104
|
+
rows,
|
|
1105
|
+
sourceType,
|
|
1106
|
+
}),
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
if (!response.ok) {
|
|
1110
|
+
const body = await parseJsonResponse<{ error: string; code?: string }>(response, 'Dataset upload')
|
|
1111
|
+
throw new Error(`Upload failed: ${body.error}`)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const data = await parseJsonResponse<{
|
|
1115
|
+
dataset: { id: string; name: string; rowCount: number; sourceType: string; url?: string }
|
|
1116
|
+
}>(response, 'Dataset upload')
|
|
1117
|
+
|
|
1118
|
+
console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`)
|
|
1119
|
+
if (data.dataset.url) {
|
|
1120
|
+
console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`)
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function downloadAnnotations() {
|
|
1125
|
+
let taskId = getArg('--task')
|
|
1126
|
+
const format = (getArg('--format') || 'jsonl') as 'csv' | 'json' | 'jsonl'
|
|
1127
|
+
const outPathArg = getArg('--out')
|
|
1128
|
+
|
|
1129
|
+
if (!['csv', 'json', 'jsonl'].includes(format)) {
|
|
1130
|
+
throw new Error('format must be one of: csv, json, jsonl')
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (!taskId) {
|
|
1134
|
+
taskId = await selectTaskIdInteractively()
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const response = await authedFetch(`/api/cli/tasks/${taskId}/export?format=${format}`)
|
|
1138
|
+
if (!response.ok) {
|
|
1139
|
+
throw new Error(`Download failed: ${await response.text()}`)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const fallbackName = `${taskId}.${format}`
|
|
1143
|
+
const filename = outPathArg
|
|
1144
|
+
? expandHomePath(outPathArg)
|
|
1145
|
+
: fallbackName
|
|
1146
|
+
|
|
1147
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
1148
|
+
writeFileSync(filename, buffer)
|
|
1149
|
+
|
|
1150
|
+
console.log(`Saved ${format.toUpperCase()} export to ${filename}`)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function main() {
|
|
1154
|
+
const command = process.argv[2]
|
|
1155
|
+
const subcommand = process.argv[3]
|
|
1156
|
+
|
|
1157
|
+
if (!command) {
|
|
1158
|
+
printUsage()
|
|
1159
|
+
process.exit(1)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (command === 'login') {
|
|
1163
|
+
await login()
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (command === 'logout') {
|
|
1168
|
+
await logout()
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (command === 'whoami') {
|
|
1173
|
+
await whoami()
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (command === 'teams' && subcommand === 'list') {
|
|
1178
|
+
await listTeams()
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (command === 'teams' && subcommand === 'create') {
|
|
1183
|
+
await createTeam()
|
|
1184
|
+
return
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const teamsMembersAction = process.argv[4]
|
|
1188
|
+
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'list') {
|
|
1189
|
+
await listTeamMembers()
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'add') {
|
|
1193
|
+
await addTeamMember()
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'remove') {
|
|
1197
|
+
await removeTeamMember()
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
if (command === 'teams' && subcommand === 'members' && teamsMembersAction === 'role') {
|
|
1201
|
+
await changeTeamMemberRole()
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (command === 'projects' && subcommand === 'list') {
|
|
1206
|
+
await listProjects()
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (command === 'projects' && subcommand === 'create') {
|
|
1211
|
+
await createProject()
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (command === 'apps' && subcommand === 'list') {
|
|
1216
|
+
await listApps()
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (command === 'apps' && subcommand === 'create') {
|
|
1221
|
+
await createAppFromFile()
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (command === 'apps' && subcommand === 'update') {
|
|
1226
|
+
await updateAppFromFile()
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (command === 'apps' && subcommand === 'link-dataset') {
|
|
1231
|
+
await linkAppDataset()
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (command === 'tasks' && subcommand === 'list') {
|
|
1236
|
+
await listTasks()
|
|
1237
|
+
return
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (command === 'tasks' && subcommand === 'create') {
|
|
1241
|
+
await createTask()
|
|
1242
|
+
return
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (command === 'tasks' && subcommand === 'assign') {
|
|
1246
|
+
await assignTask()
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (command === 'tasks' && subcommand === 'status') {
|
|
1251
|
+
await taskStatus()
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (command === 'datasets' && subcommand === 'upload') {
|
|
1256
|
+
await uploadDataset()
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (command === 'tasks' && subcommand === 'export') {
|
|
1261
|
+
await downloadAnnotations()
|
|
1262
|
+
return
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
printUsage()
|
|
1266
|
+
process.exit(1)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
main().catch(error => {
|
|
1270
|
+
console.error(error instanceof Error ? error.message : 'Unknown error')
|
|
1271
|
+
process.exit(1)
|
|
1272
|
+
})
|