orizu 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from 'crypto';
3
3
  import { basename, extname } from 'path';
4
+ import { pathToFileURL } from 'url';
4
5
  import { createServer } from 'http';
5
6
  import { readFileSync, statSync, writeFileSync } from 'fs';
6
7
  import { spawn } from 'child_process';
@@ -11,10 +12,23 @@ import { parseDatasetFile } from './file-parser.js';
11
12
  import { streamJsonlRowChunks } from './jsonl-stream.js';
12
13
  import { parseDatasetReference } from './dataset-download.js';
13
14
  import { parseGlobalFlags } from './global-flags.js';
14
- import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js';
15
+ import { assertSecureTokenTransport, authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags, } from './http.js';
15
16
  import { formatTaskCreateError } from './task-create-error.js';
17
+ function getCliVersion() {
18
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
19
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
20
+ throw new Error('Unable to read orizu CLI version.');
21
+ }
22
+ return packageJson.version;
23
+ }
24
+ function printLine(message = '') {
25
+ output.write(`${message}\n`);
26
+ }
27
+ function printVersion() {
28
+ printLine(`orizu ${getCliVersion()}`);
29
+ }
16
30
  function printUsage() {
17
- console.log(`orizu global options:\n\n --local Use http://localhost:3000\n --server <url> Use a specific server origin (for example: https://preview.example.com)\n\norizu commands:\n\n orizu login\n orizu logout\n orizu whoami\n orizu teams list\n orizu teams create [--name <name>]\n orizu teams members list [--team <teamSlug>]\n orizu teams members add --email <email> [--team <teamSlug>]\n orizu teams members remove --email <email> [--team <teamSlug>]\n orizu teams members role --team <teamSlug> --email <email> --role <admin|member>\n orizu projects list [--team <teamSlug>]\n orizu projects create --name <name> [--team <teamSlug>]\n orizu apps list [--project <team/project>]\n orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]\n orizu apps detail --app <appId> [--project <team/project>] [--json]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu tasks pause --task <taskId>\n orizu tasks unpause --task <taskId>\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>\n orizu datasets lock [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--reason <text>]\n orizu datasets clone [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`);
31
+ printLine(`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 --version, -v Print the orizu CLI version\n\norizu commands:\n\n orizu login\n orizu logout\n orizu whoami\n orizu teams list\n orizu teams create [--name <name>]\n orizu teams members list [--team <teamSlug>]\n orizu teams members add --email <email> [--team <teamSlug>]\n orizu teams members remove --email <email> [--team <teamSlug>]\n orizu teams members role --team <teamSlug> --email <email> --role <admin|member>\n orizu projects list [--team <teamSlug>]\n orizu projects create --name <name> [--team <teamSlug>]\n orizu apps list [--project <team/project>]\n orizu apps create --project <team/project> --name <name> --dataset <datasetId> --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps update [--app <appId>] [--project <team/project>] --file <path> --input-schema <json-path> --output-schema <json-path> [--component <name>]\n orizu apps link-dataset --dataset <datasetId> [--app <appId>] [--project <team/project>] [--version <n>]\n orizu apps detail --app <appId> [--project <team/project>] [--json]\n orizu tasks list [--project <team/project>]\n orizu tasks create --project <team/project> --dataset <datasetId> --app <appId> --title <title> --assignees <userIdOrEmail1,userIdOrEmail2> [--version <n>] [--instructions <text>] [--labels-per-item <n>] [--json]\n orizu tasks assign --task <taskId> --assignees <userId1,userId2>\n orizu tasks status --task <taskId> [--json]\n orizu tasks pause --task <taskId>\n orizu tasks unpause --task <taskId>\n orizu datasets upload --file <path> [--project <team/project>] [--name <name>]\n orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]\n orizu datasets append [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets edit-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --file <path>\n orizu datasets delete-rows [--dataset <datasetId|datasetUrl>] [--project <team/project>] --row-ids <id1,id2>\n orizu datasets delete [--dataset <datasetId|datasetUrl>] [--project <team/project>]\n orizu datasets lock [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--reason <text>]\n orizu datasets clone [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--name <name>]\n orizu tasks export [--task <taskId>] [--format <csv|json|jsonl>] [--out <path>]`);
18
32
  }
19
33
  let cliArgs = process.argv.slice(2);
20
34
  function getArg(name) {
@@ -24,7 +38,7 @@ function getArg(name) {
24
38
  }
25
39
  return cliArgs[index + 1];
26
40
  }
27
- function normalizeSlugInput(slug) {
41
+ export function normalizeSlugInput(slug) {
28
42
  return slug.trim().toLowerCase();
29
43
  }
30
44
  function isInteractiveTerminal() {
@@ -33,7 +47,7 @@ function isInteractiveTerminal() {
33
47
  function hasArg(name) {
34
48
  return cliArgs.includes(name);
35
49
  }
36
- function expandHomePath(path) {
50
+ export function expandHomePath(path) {
37
51
  if (path.startsWith('~/')) {
38
52
  const home = process.env.HOME || '';
39
53
  return `${home}/${path.slice(2)}`;
@@ -43,50 +57,84 @@ function expandHomePath(path) {
43
57
  function createCodeVerifier() {
44
58
  return randomBytes(32).toString('base64url');
45
59
  }
46
- function createCodeChallenge(verifier) {
60
+ export function createCodeChallenge(verifier) {
47
61
  return createHash('sha256').update(verifier).digest('base64url');
48
62
  }
63
+ export function sanitizeTerminalText(value) {
64
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
65
+ }
66
+ export function validateBrowserUrl(url, expectedOrigin) {
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(url);
70
+ }
71
+ catch {
72
+ throw new Error('Server returned an invalid browser URL.');
73
+ }
74
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
75
+ throw new Error('Server returned an unsupported browser URL scheme.');
76
+ }
77
+ if (parsed.username || parsed.password) {
78
+ throw new Error('Server returned a browser URL containing credentials.');
79
+ }
80
+ if (expectedOrigin && parsed.origin !== expectedOrigin) {
81
+ throw new Error('Server returned a browser URL for an unexpected origin.');
82
+ }
83
+ return parsed;
84
+ }
49
85
  function openInBrowser(url) {
86
+ const parsed = validateBrowserUrl(url);
50
87
  const platform = process.platform;
88
+ const href = parsed.href;
51
89
  if (platform === 'darwin') {
52
- spawn('open', [url], {
90
+ spawn('open', [href], {
53
91
  detached: true,
54
92
  stdio: 'ignore',
55
93
  }).unref();
56
94
  return;
57
95
  }
58
96
  if (platform === 'win32') {
59
- spawn('cmd', ['/c', 'start', '', url], {
97
+ spawn('rundll32.exe', ['url.dll,FileProtocolHandler', href], {
60
98
  detached: true,
61
99
  stdio: 'ignore',
62
100
  windowsHide: true,
63
101
  }).unref();
64
102
  return;
65
103
  }
66
- spawn('xdg-open', [url], {
104
+ spawn('xdg-open', [href], {
67
105
  detached: true,
68
106
  stdio: 'ignore',
69
107
  }).unref();
70
108
  }
71
- function formatTerminalLink(url) {
109
+ export function formatTerminalLink(url) {
110
+ const safeUrl = sanitizeTerminalText(url);
111
+ try {
112
+ const parsed = validateBrowserUrl(safeUrl);
113
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
114
+ return safeUrl;
115
+ }
116
+ }
117
+ catch {
118
+ return safeUrl;
119
+ }
72
120
  if (!isInteractiveTerminal()) {
73
- return url;
121
+ return safeUrl;
74
122
  }
75
- return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`;
123
+ return `\u001B]8;;${safeUrl}\u0007${safeUrl}\u001B]8;;\u0007`;
76
124
  }
77
- async function parseJsonResponse(response, context) {
125
+ export async function parseJsonResponse(response, context) {
78
126
  const contentType = response.headers.get('content-type') || '';
79
127
  const rawBody = await response.text();
80
128
  if (!contentType.includes('application/json')) {
81
129
  throw new Error(`${context} returned non-JSON response (status ${response.status}). ` +
82
- `Body preview: ${rawBody.slice(0, 180)}`);
130
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
83
131
  }
84
132
  try {
85
133
  return JSON.parse(rawBody);
86
134
  }
87
135
  catch {
88
136
  throw new Error(`${context} returned invalid JSON (status ${response.status}). ` +
89
- `Body preview: ${rawBody.slice(0, 180)}`);
137
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
90
138
  }
91
139
  }
92
140
  async function promptSelect(title, items, label, options) {
@@ -99,9 +147,9 @@ async function promptSelect(title, items, label, options) {
99
147
  if (items.length === 1 && !options?.forcePrompt) {
100
148
  return items[0];
101
149
  }
102
- console.log(`\n${title}`);
150
+ printLine(`\n${sanitizeTerminalText(title)}`);
103
151
  items.forEach((item, index) => {
104
- console.log(` ${index + 1}. ${label(item, index)}`);
152
+ printLine(` ${index + 1}. ${sanitizeTerminalText(label(item, index))}`);
105
153
  });
106
154
  const rl = createInterface({ input, output });
107
155
  try {
@@ -111,7 +159,7 @@ async function promptSelect(title, items, label, options) {
111
159
  if (Number.isInteger(chosenIndex) && chosenIndex >= 1 && chosenIndex <= items.length) {
112
160
  return items[chosenIndex - 1];
113
161
  }
114
- console.log('Invalid selection. Enter a valid number from the list.');
162
+ printLine('Invalid selection. Enter a valid number from the list.');
115
163
  }
116
164
  }
117
165
  finally {
@@ -187,7 +235,7 @@ async function resolveProjectSlug(projectArg) {
187
235
  const [teamSlug, projectSlug] = segments.map(normalizeSlugInput);
188
236
  const matchedTeam = teams.find(team => team.slug === teamSlug);
189
237
  if (!matchedTeam) {
190
- console.error(`Team '${teamSlug}' not found in your accessible teams.`);
238
+ console.error(`Team '${sanitizeTerminalText(teamSlug)}' not found in your accessible teams.`);
191
239
  const selectedTeam = await promptSelect('Select a team', teams, team => `${team.name} (${team.slug})`);
192
240
  const projects = await fetchProjects(selectedTeam.slug);
193
241
  const selectedProject = await promptSelect(`Select a project in ${selectedTeam.slug}`, projects, project => `${project.name} (${project.teamSlug}/${project.slug})`);
@@ -196,7 +244,7 @@ async function resolveProjectSlug(projectArg) {
196
244
  const projects = await fetchProjects(matchedTeam.slug);
197
245
  const matchedProject = projects.find(project => project.slug === projectSlug);
198
246
  if (!matchedProject) {
199
- console.error(`Project '${projectSlug}' not found in team '${matchedTeam.slug}'.`);
247
+ console.error(`Project '${sanitizeTerminalText(projectSlug)}' not found in team '${sanitizeTerminalText(matchedTeam.slug)}'.`);
200
248
  const selectedProject = await promptSelect(`Select a project in ${matchedTeam.slug}`, projects, project => `${project.name} (${project.teamSlug}/${project.slug})`);
201
249
  return `${selectedProject.teamSlug}/${selectedProject.slug}`;
202
250
  }
@@ -231,120 +279,121 @@ async function selectDatasetInteractively(projectArg) {
231
279
  return {
232
280
  datasetId: dataset.id,
233
281
  project,
282
+ name: dataset.name,
234
283
  };
235
284
  }
236
285
  function printTeams(teams) {
237
286
  if (teams.length === 0) {
238
- console.log('No teams found.');
287
+ printLine('No teams found.');
239
288
  return;
240
289
  }
241
290
  const rows = teams.map(team => ({
242
- slug: team.slug,
243
- name: team.name || '-',
244
- role: team.role || '-',
291
+ slug: sanitizeTerminalText(team.slug),
292
+ name: sanitizeTerminalText(team.name || '-'),
293
+ role: sanitizeTerminalText(team.role || '-'),
245
294
  }));
246
295
  const slugWidth = Math.max('TEAM SLUG'.length, ...rows.map(row => row.slug.length));
247
296
  const nameWidth = Math.max('TEAM NAME'.length, ...rows.map(row => row.name.length));
248
297
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length));
249
- console.log(`${'TEAM SLUG'.padEnd(slugWidth)} ${'TEAM NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`);
250
- console.log(`${'-'.repeat(slugWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`);
298
+ printLine(`${'TEAM SLUG'.padEnd(slugWidth)} ${'TEAM NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`);
299
+ printLine(`${'-'.repeat(slugWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`);
251
300
  rows.forEach(row => {
252
- console.log(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`);
301
+ printLine(`${row.slug.padEnd(slugWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`);
253
302
  });
254
303
  }
255
304
  function printProjects(projects) {
256
305
  if (projects.length === 0) {
257
- console.log('No projects found.');
306
+ printLine('No projects found.');
258
307
  return;
259
308
  }
260
309
  const rows = projects.map(project => ({
261
- project: `${project.teamSlug}/${project.slug}`,
262
- name: project.name || '-',
263
- role: project.role || '-',
310
+ project: sanitizeTerminalText(`${project.teamSlug}/${project.slug}`),
311
+ name: sanitizeTerminalText(project.name || '-'),
312
+ role: sanitizeTerminalText(project.role || '-'),
264
313
  }));
265
314
  const projectWidth = Math.max('TEAM/PROJECT'.length, ...rows.map(row => row.project.length));
266
315
  const nameWidth = Math.max('PROJECT NAME'.length, ...rows.map(row => row.name.length));
267
316
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length));
268
- console.log(`${'TEAM/PROJECT'.padEnd(projectWidth)} ${'PROJECT NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`);
269
- console.log(`${'-'.repeat(projectWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`);
317
+ printLine(`${'TEAM/PROJECT'.padEnd(projectWidth)} ${'PROJECT NAME'.padEnd(nameWidth)} ${'ROLE'.padEnd(roleWidth)}`);
318
+ printLine(`${'-'.repeat(projectWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(roleWidth)}`);
270
319
  rows.forEach(row => {
271
- console.log(`${row.project.padEnd(projectWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`);
320
+ printLine(`${row.project.padEnd(projectWidth)} ${row.name.padEnd(nameWidth)} ${row.role.padEnd(roleWidth)}`);
272
321
  });
273
322
  }
274
323
  function printTasks(tasks) {
275
324
  if (tasks.length === 0) {
276
- console.log('No tasks found.');
325
+ printLine('No tasks found.');
277
326
  return;
278
327
  }
279
328
  const rows = tasks.map(task => ({
280
- id: task.id,
281
- name: task.title || '-',
282
- status: task.status || '-',
329
+ id: sanitizeTerminalText(task.id),
330
+ name: sanitizeTerminalText(task.title || '-'),
331
+ status: sanitizeTerminalText(task.status || '-'),
283
332
  project: task.teamSlug && task.projectSlug
284
- ? `${task.teamSlug}/${task.projectSlug}`
333
+ ? sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)
285
334
  : 'unknown-project',
286
335
  }));
287
336
  const idWidth = Math.max('TASK ID'.length, ...rows.map(row => row.id.length));
288
337
  const nameWidth = Math.max('TASK NAME'.length, ...rows.map(row => row.name.length));
289
338
  const statusWidth = Math.max('STATUS'.length, ...rows.map(row => row.status.length));
290
- console.log(`${'TASK ID'.padEnd(idWidth)} ${'TASK NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} TEAM/PROJECT`);
291
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(statusWidth)} ------------`);
339
+ printLine(`${'TASK ID'.padEnd(idWidth)} ${'TASK NAME'.padEnd(nameWidth)} ${'STATUS'.padEnd(statusWidth)} TEAM/PROJECT`);
340
+ printLine(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(statusWidth)} ------------`);
292
341
  rows.forEach(row => {
293
- console.log(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.project}`);
342
+ printLine(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.project}`);
294
343
  });
295
344
  }
296
345
  function printApps(apps) {
297
346
  if (apps.length === 0) {
298
- console.log('No apps found.');
347
+ printLine('No apps found.');
299
348
  return;
300
349
  }
301
350
  const rows = apps.map(app => ({
302
- id: app.id,
303
- name: app.name || '-',
351
+ id: sanitizeTerminalText(app.id),
352
+ name: sanitizeTerminalText(app.name || '-'),
304
353
  version: `v${app.currentVersionNum || 1}`,
305
354
  }));
306
355
  const idWidth = Math.max('APP ID'.length, ...rows.map(row => row.id.length));
307
356
  const nameWidth = Math.max('APP NAME'.length, ...rows.map(row => row.name.length));
308
357
  const versionWidth = Math.max('VERSION'.length, ...rows.map(row => row.version.length));
309
- console.log(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`);
310
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`);
358
+ printLine(`${'APP ID'.padEnd(idWidth)} ${'APP NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)}`);
359
+ printLine(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)}`);
311
360
  rows.forEach(row => {
312
- console.log(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`);
361
+ printLine(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)}`);
313
362
  });
314
363
  }
315
364
  function printTeamMembers(members) {
316
365
  if (members.length === 0) {
317
- console.log('No team members found.');
366
+ printLine('No team members found.');
318
367
  return;
319
368
  }
320
369
  const rows = members.map(member => ({
321
- id: member.id,
322
- userId: member.user_id || '-',
323
- email: member.email || '-',
324
- role: member.role || '-',
370
+ id: sanitizeTerminalText(member.id),
371
+ userId: sanitizeTerminalText(member.user_id || '-'),
372
+ email: sanitizeTerminalText(member.email || '-'),
373
+ role: sanitizeTerminalText(member.role || '-'),
325
374
  }));
326
375
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length));
327
376
  const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length));
328
377
  const emailWidth = Math.max('EMAIL'.length, ...rows.map(row => row.email.length));
329
378
  const roleWidth = Math.max('ROLE'.length, ...rows.map(row => row.role.length));
330
- console.log(`${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`);
331
- console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`);
379
+ printLine(`${'MEMBER ID'.padEnd(idWidth)} ${'USER ID'.padEnd(userIdWidth)} ${'EMAIL'.padEnd(emailWidth)} ${'ROLE'.padEnd(roleWidth)}`);
380
+ printLine(`${'-'.repeat(idWidth)} ${'-'.repeat(userIdWidth)} ${'-'.repeat(emailWidth)} ${'-'.repeat(roleWidth)}`);
332
381
  rows.forEach(row => {
333
- console.log(`${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`);
382
+ printLine(`${row.id.padEnd(idWidth)} ${row.userId.padEnd(userIdWidth)} ${row.email.padEnd(emailWidth)} ${row.role.padEnd(roleWidth)}`);
334
383
  });
335
384
  }
336
385
  function printTaskStatusSummary(data) {
337
386
  const task = data.task;
338
- console.log(`Task: ${task.title} (${task.id})`);
339
- console.log(`Status: ${task.status}`);
340
- console.log(`Project: ${task.teamSlug}/${task.projectSlug}`);
341
- console.log(`Progress: ${task.progressPercentage}%`);
342
- console.log(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`);
343
- console.log(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`);
387
+ printLine(`Task: ${sanitizeTerminalText(task.title)} (${sanitizeTerminalText(task.id)})`);
388
+ printLine(`Status: ${sanitizeTerminalText(task.status)}`);
389
+ printLine(`Project: ${sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)}`);
390
+ printLine(`Progress: ${task.progressPercentage}%`);
391
+ printLine(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`);
392
+ printLine(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`);
344
393
  if (task.assignees.length > 0) {
345
- console.log('\nAssignees');
394
+ printLine('\nAssignees');
346
395
  task.assignees.forEach(assignee => {
347
- console.log(` ${assignee.email}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`);
396
+ printLine(` ${sanitizeTerminalText(assignee.email)}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`);
348
397
  });
349
398
  }
350
399
  }
@@ -469,6 +518,7 @@ function renderCliAuthBrowserPage(status) {
469
518
  }
470
519
  async function login() {
471
520
  const baseUrl = resolveLoginBaseUrl();
521
+ assertSecureTokenTransport(baseUrl);
472
522
  const codeVerifier = createCodeVerifier();
473
523
  const codeChallenge = createCodeChallenge(codeVerifier);
474
524
  const callbackPort = resolveAuthCallbackPort();
@@ -517,8 +567,9 @@ async function login() {
517
567
  return;
518
568
  }
519
569
  const { authorizeUrl } = await parseJsonResponse(response, 'CLI auth start');
520
- console.log(`Opening browser for login: ${authorizeUrl}`);
521
- openInBrowser(authorizeUrl);
570
+ const safeAuthorizeUrl = validateBrowserUrl(authorizeUrl, baseUrl).href;
571
+ printLine(`Opening browser for login: ${sanitizeTerminalText(safeAuthorizeUrl)}`);
572
+ openInBrowser(safeAuthorizeUrl);
522
573
  }
523
574
  catch (error) {
524
575
  server.close();
@@ -536,12 +587,14 @@ async function login() {
536
587
  throw new Error(`Failed to exchange auth code: ${text}`);
537
588
  }
538
589
  const loginData = await parseJsonResponse(exchangeResponse, 'CLI auth exchange');
590
+ if (!loginData.apiKey) {
591
+ throw new Error('Server did not return an API key. Upgrade the Orizu server and run `orizu login` again.');
592
+ }
539
593
  saveServerCredentials(baseUrl, {
540
- accessToken: loginData.accessToken,
541
- refreshToken: loginData.refreshToken,
542
- expiresAt: loginData.expiresAt,
594
+ credentialType: 'pat',
595
+ apiKey: loginData.apiKey,
543
596
  });
544
- console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`);
597
+ printLine(`Logged in as ${sanitizeTerminalText(loginData.user.email ?? loginData.user.id)}`);
545
598
  }
546
599
  async function whoami() {
547
600
  const response = await authedFetch('/api/cli/auth/whoami');
@@ -549,25 +602,41 @@ async function whoami() {
549
602
  throw new Error(`whoami failed: ${await response.text()}`);
550
603
  }
551
604
  const data = await response.json();
552
- console.log(data.user.email ?? data.user.id);
605
+ printLine(sanitizeTerminalText(data.user.email ?? data.user.id));
553
606
  }
554
607
  async function logout() {
555
608
  const baseUrl = getBaseUrl();
556
609
  const credentials = getServerCredentials(baseUrl);
557
610
  if (!credentials) {
558
- console.log(`Already logged out for ${baseUrl}.`);
611
+ printLine(`Already logged out for ${sanitizeTerminalText(baseUrl)}.`);
559
612
  return;
560
613
  }
561
- await fetch(`${baseUrl}/api/cli/auth/logout`, {
562
- method: 'POST',
563
- headers: {
564
- 'Content-Type': 'application/json',
565
- Authorization: `Bearer ${credentials.accessToken}`,
566
- },
567
- body: JSON.stringify({ refreshToken: credentials.refreshToken }),
568
- }).catch(() => undefined);
614
+ let remoteLogoutError = null;
615
+ try {
616
+ assertSecureTokenTransport(baseUrl);
617
+ const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
618
+ method: 'POST',
619
+ headers: {
620
+ 'Content-Type': 'application/json',
621
+ Authorization: `Bearer ${'accessToken' in credentials ? credentials.accessToken : credentials.apiKey}`,
622
+ },
623
+ body: 'refreshToken' in credentials
624
+ ? JSON.stringify({ refreshToken: credentials.refreshToken })
625
+ : undefined,
626
+ });
627
+ if (!response.ok) {
628
+ remoteLogoutError = sanitizeTerminalText(await response.text()).slice(0, 180);
629
+ }
630
+ }
631
+ catch (error) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ remoteLogoutError = sanitizeTerminalText(message).slice(0, 180);
634
+ }
569
635
  clearServerCredentials(baseUrl);
570
- console.log(`Logged out from ${baseUrl}.`);
636
+ if (remoteLogoutError) {
637
+ console.warn(`Warning: remote logout failed: ${remoteLogoutError}`);
638
+ }
639
+ printLine(`Logged out from ${sanitizeTerminalText(baseUrl)}.`);
571
640
  }
572
641
  async function listTeams() {
573
642
  printTeams(await fetchTeams());
@@ -602,7 +671,7 @@ async function createTeam() {
602
671
  throw new Error(`Failed to create team: ${await response.text()}`);
603
672
  }
604
673
  const data = await parseJsonResponse(response, 'Team create');
605
- console.log(`Created team: ${data.team.name} (${data.team.slug})`);
674
+ printLine(`Created team: ${sanitizeTerminalText(data.team.name)} (${sanitizeTerminalText(data.team.slug)})`);
606
675
  }
607
676
  async function listProjects() {
608
677
  const teamSlugArg = getArg('--team');
@@ -629,7 +698,7 @@ async function createProject() {
629
698
  throw new Error(`Failed to create project: ${await response.text()}`);
630
699
  }
631
700
  const data = await parseJsonResponse(response, 'Project create');
632
- console.log(`Created project ${data.project.teamSlug}/${data.project.slug}`);
701
+ printLine(`Created project ${sanitizeTerminalText(`${data.project.teamSlug}/${data.project.slug}`)}`);
633
702
  }
634
703
  async function listTasks() {
635
704
  const project = getArg('--project');
@@ -695,9 +764,9 @@ async function createAppFromFile() {
695
764
  throw new Error(`Failed to create app: ${await response.text()}`);
696
765
  }
697
766
  const data = await parseJsonResponse(response, 'App create');
698
- console.log(`Created app ${data.app.name} (${data.app.id}) v${data.app.versionNum}`);
767
+ printLine(`Created app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) v${data.app.versionNum}`);
699
768
  if (data.warnings?.length) {
700
- console.log(`Warnings: ${data.warnings.join('; ')}`);
769
+ printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`);
701
770
  }
702
771
  }
703
772
  async function updateAppFromFile() {
@@ -731,9 +800,9 @@ async function updateAppFromFile() {
731
800
  throw new Error(`Failed to update app: ${await response.text()}`);
732
801
  }
733
802
  const data = await parseJsonResponse(response, 'App update');
734
- console.log(`Updated app ${data.app.name} (${data.app.id}) to v${data.app.versionNum}`);
803
+ printLine(`Updated app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) to v${data.app.versionNum}`);
735
804
  if (data.warnings?.length) {
736
- console.log(`Warnings: ${data.warnings.join('; ')}`);
805
+ printLine(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`);
737
806
  }
738
807
  }
739
808
  async function linkAppDataset() {
@@ -765,7 +834,7 @@ async function linkAppDataset() {
765
834
  throw new Error(`Failed to link dataset: ${await response.text()}`);
766
835
  }
767
836
  const data = await parseJsonResponse(response, 'App link dataset');
768
- console.log(`Linked dataset ${data.linkedDataset.name} (${data.linkedDataset.id}) to app ${data.app.name} (${data.app.id}) version ${data.versionNum}`);
837
+ printLine(`Linked dataset ${sanitizeTerminalText(data.linkedDataset.name)} (${sanitizeTerminalText(data.linkedDataset.id)}) to app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) version ${data.versionNum}`);
769
838
  }
770
839
  function parseCommaSeparated(value) {
771
840
  if (!value) {
@@ -812,7 +881,7 @@ async function createTask() {
812
881
  const cliError = await formatTaskCreateError(response);
813
882
  if (hasArg('--json')) {
814
883
  const payload = cliError.structuredPayload ?? { error: cliError.message };
815
- console.log(JSON.stringify({
884
+ printLine(JSON.stringify({
816
885
  ...payload,
817
886
  httpStatus: cliError.httpStatus,
818
887
  }, null, 2));
@@ -821,7 +890,7 @@ async function createTask() {
821
890
  }
822
891
  const data = await parseJsonResponse(response, 'Task create');
823
892
  if (hasArg('--json')) {
824
- console.log(JSON.stringify({
893
+ printLine(JSON.stringify({
825
894
  taskId: data.task.id,
826
895
  datasetId,
827
896
  versionId: data.task.versionId,
@@ -838,14 +907,14 @@ async function createTask() {
838
907
  }
839
908
  const baseUrl = getBaseUrl();
840
909
  const taskUrl = `${baseUrl}/d/${projectSlug}/tasks/${data.task.id}`;
841
- console.log(`Created task ${data.task.title} (${data.task.id}) [${data.task.status}]` +
842
- `\n Task ID: ${data.task.id}` +
843
- `\n Dataset ID: ${datasetId}` +
844
- `\n Version: v${data.task.versionNum} (${data.task.versionId})` +
910
+ printLine(`Created task ${sanitizeTerminalText(data.task.title)} (${sanitizeTerminalText(data.task.id)}) [${sanitizeTerminalText(data.task.status)}]` +
911
+ `\n Task ID: ${sanitizeTerminalText(data.task.id)}` +
912
+ `\n Dataset ID: ${sanitizeTerminalText(datasetId)}` +
913
+ `\n Version: v${data.task.versionNum} (${sanitizeTerminalText(data.task.versionId)})` +
845
914
  `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
846
915
  `\n Assignments: ${data.assignmentsCreated}` +
847
- (data.warning ? `\n Warning: ${data.warning}` : '') +
848
- `\n URL: ${taskUrl}`);
916
+ (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '') +
917
+ `\n URL: ${sanitizeTerminalText(taskUrl)}`);
849
918
  }
850
919
  async function assignTask() {
851
920
  const taskId = getArg('--task');
@@ -862,7 +931,7 @@ async function assignTask() {
862
931
  throw new Error(`Failed to assign task: ${await response.text()}`);
863
932
  }
864
933
  const data = await parseJsonResponse(response, 'Task assign');
865
- console.log(`Created ${data.assignmentsCreated} assignments.`);
934
+ printLine(`Created ${data.assignmentsCreated} assignments.`);
866
935
  }
867
936
  async function taskStatus() {
868
937
  const taskId = getArg('--task');
@@ -883,7 +952,7 @@ async function taskStatus() {
883
952
  catch {
884
953
  // keep raw body as error
885
954
  }
886
- console.log(JSON.stringify({
955
+ printLine(JSON.stringify({
887
956
  ...errorPayload,
888
957
  httpStatus: response.status,
889
958
  }, null, 2));
@@ -900,7 +969,7 @@ async function taskStatus() {
900
969
  }
901
970
  const data = await parseJsonResponse(response, 'Task status');
902
971
  if (hasArg('--json')) {
903
- console.log(JSON.stringify(data, null, 2));
972
+ printLine(JSON.stringify(data, null, 2));
904
973
  return;
905
974
  }
906
975
  printTaskStatusSummary(data);
@@ -922,7 +991,7 @@ async function updateTaskStatus(targetStatus) {
922
991
  }
923
992
  const data = await parseJsonResponse(response, 'Task status update');
924
993
  const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused';
925
- console.log(`${action} task ${data.task.id} [${data.task.status}]`);
994
+ printLine(`${action} task ${sanitizeTerminalText(data.task.id)} [${sanitizeTerminalText(data.task.status)}]`);
926
995
  }
927
996
  async function appDetail() {
928
997
  const appId = getArg('--app');
@@ -942,25 +1011,25 @@ async function appDetail() {
942
1011
  throw new Error(`App '${appId}' not found in project '${projectSlug}'`);
943
1012
  }
944
1013
  if (hasArg('--json')) {
945
- console.log(JSON.stringify(detail, null, 2));
1014
+ printLine(JSON.stringify(detail, null, 2));
946
1015
  return;
947
1016
  }
948
- console.log(`App: ${detail.name} (${detail.id})`);
949
- console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`);
1017
+ printLine(`App: ${sanitizeTerminalText(detail.name)} (${sanitizeTerminalText(detail.id)})`);
1018
+ printLine(` Project: ${sanitizeTerminalText(`${detail.teamSlug}/${detail.projectSlug}`)}`);
950
1019
  if (detail.currentVersion) {
951
- console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`);
952
- console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`);
953
- console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`);
1020
+ printLine(` Current version: v${detail.currentVersion.versionNum} (${sanitizeTerminalText(detail.currentVersion.versionId)})`);
1021
+ printLine(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`);
1022
+ printLine(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`);
954
1023
  }
955
1024
  else {
956
- console.log(` Current version: none`);
1025
+ printLine(` Current version: none`);
957
1026
  }
958
- console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`);
1027
+ printLine(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`);
959
1028
  if (detail.createdByEmail) {
960
- console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`);
1029
+ printLine(` Created by: ${sanitizeTerminalText(detail.createdByName || detail.createdByEmail)}`);
961
1030
  }
962
- console.log(` Created: ${detail.createdAt}`);
963
- console.log(` Updated: ${detail.updatedAt}`);
1031
+ printLine(` Created: ${sanitizeTerminalText(detail.createdAt)}`);
1032
+ printLine(` Updated: ${sanitizeTerminalText(detail.updatedAt)}`);
964
1033
  }
965
1034
  async function listTeamMembers() {
966
1035
  const teamSlug = await resolveTeamSlug(getArg('--team'));
@@ -981,7 +1050,7 @@ async function addTeamMember() {
981
1050
  throw new Error(`Failed to add team member: ${await response.text()}`);
982
1051
  }
983
1052
  const data = await parseJsonResponse(response, 'Team member add');
984
- console.log(`Added team member ${data.member.email} (${data.member.id})`);
1053
+ printLine(`Added team member ${sanitizeTerminalText(data.member.email)} (${sanitizeTerminalText(data.member.id)})`);
985
1054
  }
986
1055
  async function removeTeamMember() {
987
1056
  const teamSlug = await resolveTeamSlug(getArg('--team'));
@@ -998,7 +1067,7 @@ async function removeTeamMember() {
998
1067
  if (!response.ok) {
999
1068
  throw new Error(`Failed to remove team member: ${await response.text()}`);
1000
1069
  }
1001
- console.log(`Removed team member ${member.email}`);
1070
+ printLine(`Removed team member ${sanitizeTerminalText(member.email)}`);
1002
1071
  }
1003
1072
  async function changeTeamMemberRole() {
1004
1073
  const teamSlugArg = getArg('--team');
@@ -1024,7 +1093,7 @@ async function changeTeamMemberRole() {
1024
1093
  if (!response.ok) {
1025
1094
  throw new Error(`Failed to update member role: ${await response.text()}`);
1026
1095
  }
1027
- console.log(`Updated ${member.email} role to ${role}`);
1096
+ printLine(`Updated ${sanitizeTerminalText(member.email)} role to ${sanitizeTerminalText(role)}`);
1028
1097
  }
1029
1098
  async function createDatasetFromRows(project, name, sourceType, rows) {
1030
1099
  const response = await authedFetch('/api/cli/datasets/upload', {
@@ -1061,7 +1130,7 @@ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
1061
1130
  throw new Error(message);
1062
1131
  }
1063
1132
  throw new Error(`Upload stopped while reading the next JSONL chunk: ${message}\n` +
1064
- `Dataset ${dataset.name} (${dataset.id}) was created and ${totalUploaded} rows were uploaded. ` +
1133
+ `Dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) was created and ${totalUploaded} rows were uploaded. ` +
1065
1134
  `Fix the file, remove the first ${totalUploaded} rows, and run ` +
1066
1135
  `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
1067
1136
  }
@@ -1070,7 +1139,7 @@ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
1070
1139
  }
1071
1140
  const chunk = nextChunk.value;
1072
1141
  chunkIndex += 1;
1073
- console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
1142
+ printLine(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
1074
1143
  try {
1075
1144
  if (!dataset) {
1076
1145
  const data = await createDatasetFromRows(project, datasetName, 'jsonl', chunk);
@@ -1099,9 +1168,9 @@ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
1099
1168
  if (!dataset) {
1100
1169
  throw new Error('Dataset file contains no rows');
1101
1170
  }
1102
- console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`);
1171
+ printLine(`Uploaded dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) with ${dataset.rowCount} rows.`);
1103
1172
  if (dataset.url) {
1104
- console.log(`View dataset: ${formatTerminalLink(dataset.url)}`);
1173
+ printLine(`View dataset: ${formatTerminalLink(dataset.url)}`);
1105
1174
  }
1106
1175
  }
1107
1176
  async function uploadDataset() {
@@ -1120,9 +1189,9 @@ async function uploadDataset() {
1120
1189
  }
1121
1190
  const { rows, sourceType } = parseDatasetFile(file);
1122
1191
  const data = await createDatasetFromRows(project, datasetName, sourceType, rows);
1123
- console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`);
1192
+ printLine(`Uploaded dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) with ${data.dataset.rowCount} rows.`);
1124
1193
  if (data.dataset.url) {
1125
- console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
1194
+ printLine(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
1126
1195
  }
1127
1196
  }
1128
1197
  function getDatasetReferenceInput() {
@@ -1161,7 +1230,7 @@ async function downloadDataset() {
1161
1230
  : `${datasetId}.${format}`;
1162
1231
  const bytes = new Uint8Array(await response.arrayBuffer());
1163
1232
  writeFileSync(filename, bytes);
1164
- console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`);
1233
+ printLine(`Saved dataset ${sanitizeTerminalText(datasetId)} (${format.toUpperCase()}) to ${sanitizeTerminalText(filename)}`);
1165
1234
  }
1166
1235
  const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
1167
1236
  const APPEND_CHUNK_SIZE_ROWS = 500;
@@ -1197,7 +1266,7 @@ async function appendJsonlDatasetRowsInChunks(datasetId, file) {
1197
1266
  }
1198
1267
  const chunk = nextChunk.value;
1199
1268
  chunkIndex += 1;
1200
- console.log(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
1269
+ printLine(`Uploading chunk ${chunkIndex} (${chunk.length} rows)...`);
1201
1270
  try {
1202
1271
  const data = await appendChunk(datasetId, chunk);
1203
1272
  totalAppended += data.appendedCount;
@@ -1213,7 +1282,7 @@ async function appendJsonlDatasetRowsInChunks(datasetId, file) {
1213
1282
  if (!lastResult) {
1214
1283
  throw new Error('Dataset append file must contain at least one row');
1215
1284
  }
1216
- console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1285
+ printLine(`Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`);
1217
1286
  }
1218
1287
  async function appendDatasetRows() {
1219
1288
  const projectArg = getArg('--project');
@@ -1262,7 +1331,7 @@ async function appendDatasetRows() {
1262
1331
  }
1263
1332
  if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1264
1333
  const data = await appendChunk(datasetId, rows);
1265
- console.log(`Appended ${data.appendedCount} rows to dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
1334
+ printLine(`Appended ${data.appendedCount} rows to dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`);
1266
1335
  return;
1267
1336
  }
1268
1337
  // Chunked upload for large row counts (ALI-555: track partial progress)
@@ -1272,7 +1341,7 @@ async function appendDatasetRows() {
1272
1341
  for (let offset = 0; offset < rows.length; offset += APPEND_CHUNK_SIZE_ROWS) {
1273
1342
  const chunk = rows.slice(offset, offset + APPEND_CHUNK_SIZE_ROWS);
1274
1343
  const chunkIndex = Math.floor(offset / APPEND_CHUNK_SIZE_ROWS) + 1;
1275
- console.log(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`);
1344
+ printLine(`Uploading chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows)...`);
1276
1345
  try {
1277
1346
  const data = await appendChunk(datasetId, chunk);
1278
1347
  totalAppended += data.appendedCount;
@@ -1286,7 +1355,7 @@ async function appendDatasetRows() {
1286
1355
  }
1287
1356
  }
1288
1357
  if (lastResult) {
1289
- console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1358
+ printLine(`Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`);
1290
1359
  }
1291
1360
  }
1292
1361
  async function editDatasetRows() {
@@ -1332,7 +1401,7 @@ async function editDatasetRows() {
1332
1401
  throw new Error(`Edit rows failed: ${await response.text()}`);
1333
1402
  }
1334
1403
  const data = await parseJsonResponse(response, 'Dataset edit rows');
1335
- console.log(`Updated ${data.updatedCount} rows in dataset ${data.dataset.name} (${data.dataset.id}). Current row count: ${data.dataset.rowCount}`);
1404
+ printLine(`Updated ${data.updatedCount} rows in dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Current row count: ${data.dataset.rowCount}`);
1336
1405
  }
1337
1406
  async function deleteDatasetRows() {
1338
1407
  const projectArg = getArg('--project');
@@ -1360,7 +1429,45 @@ async function deleteDatasetRows() {
1360
1429
  throw new Error(`Delete rows failed: ${await response.text()}`);
1361
1430
  }
1362
1431
  const data = await parseJsonResponse(response, 'Dataset delete rows');
1363
- console.log(`Deleted ${data.deletedCount} rows from dataset ${data.dataset.name} (${data.dataset.id}). New row count: ${data.dataset.rowCount}`);
1432
+ printLine(`Deleted ${data.deletedCount} rows from dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`);
1433
+ }
1434
+ async function confirmDatasetDeletion(dataset) {
1435
+ if (!isInteractiveTerminal()) {
1436
+ throw new Error('Dataset deletion requires an interactive terminal confirmation. There is no non-interactive delete option.');
1437
+ }
1438
+ const safeName = dataset.name ? ` (${sanitizeTerminalText(dataset.name)})` : '';
1439
+ printLine(`This will permanently delete dataset ${sanitizeTerminalText(dataset.datasetId)}${safeName}.`);
1440
+ printLine('Type the dataset id exactly to confirm.');
1441
+ const rl = createInterface({ input, output });
1442
+ try {
1443
+ const answer = (await rl.question('Dataset id: ')).trim();
1444
+ if (answer !== dataset.datasetId) {
1445
+ throw new Error('Dataset deletion cancelled.');
1446
+ }
1447
+ }
1448
+ finally {
1449
+ rl.close();
1450
+ }
1451
+ }
1452
+ async function deleteDataset() {
1453
+ const projectArg = getArg('--project');
1454
+ const datasetInput = getDatasetReferenceInput();
1455
+ let dataset;
1456
+ if (datasetInput) {
1457
+ dataset = { datasetId: parseDatasetReference(datasetInput).datasetId };
1458
+ }
1459
+ else {
1460
+ dataset = await selectDatasetInteractively(projectArg);
1461
+ }
1462
+ await confirmDatasetDeletion(dataset);
1463
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(dataset.datasetId)}`, {
1464
+ method: 'DELETE',
1465
+ });
1466
+ if (!response.ok) {
1467
+ throw new Error(`Delete failed: ${await response.text()}`);
1468
+ }
1469
+ const data = await parseJsonResponse(response, 'Dataset delete');
1470
+ printLine(`Deleted dataset ${sanitizeTerminalText(data.dataset.id)}.`);
1364
1471
  }
1365
1472
  async function lockDataset() {
1366
1473
  const projectArg = getArg('--project');
@@ -1383,7 +1490,7 @@ async function lockDataset() {
1383
1490
  throw new Error(`Lock failed: ${await response.text()}`);
1384
1491
  }
1385
1492
  const data = await parseJsonResponse(response, 'Dataset lock');
1386
- console.log(`Locked dataset ${data.dataset.name} (${data.dataset.id}) at ${data.dataset.lockedAt}. Row count: ${data.dataset.rowCount}`);
1493
+ printLine(`Locked dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) at ${sanitizeTerminalText(data.dataset.lockedAt)}. Row count: ${data.dataset.rowCount}`);
1387
1494
  }
1388
1495
  async function cloneDataset() {
1389
1496
  const projectArg = getArg('--project');
@@ -1406,7 +1513,7 @@ async function cloneDataset() {
1406
1513
  throw new Error(`Clone failed: ${await response.text()}`);
1407
1514
  }
1408
1515
  const data = await parseJsonResponse(response, 'Dataset clone');
1409
- console.log(`Cloned dataset ${data.dataset.parentDatasetId} -> ${data.dataset.name} (${data.dataset.id}). Row count: ${data.dataset.rowCount}`);
1516
+ printLine(`Cloned dataset ${sanitizeTerminalText(data.dataset.parentDatasetId)} -> ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Row count: ${data.dataset.rowCount}`);
1410
1517
  }
1411
1518
  async function downloadAnnotations() {
1412
1519
  let taskId = getArg('--task');
@@ -1428,14 +1535,18 @@ async function downloadAnnotations() {
1428
1535
  : fallbackName;
1429
1536
  const bytes = new Uint8Array(await response.arrayBuffer());
1430
1537
  writeFileSync(filename, bytes);
1431
- console.log(`Saved ${format.toUpperCase()} export to ${filename}`);
1538
+ printLine(`Saved ${format.toUpperCase()} export to ${sanitizeTerminalText(filename)}`);
1432
1539
  }
1433
- async function main() {
1434
- const parsed = parseGlobalFlags(process.argv.slice(2));
1540
+ export async function main(rawArgs = process.argv.slice(2)) {
1541
+ const parsed = parseGlobalFlags(rawArgs);
1435
1542
  setGlobalFlags(parsed.flags);
1436
1543
  cliArgs = parsed.args;
1437
1544
  const command = cliArgs[0];
1438
1545
  const subcommand = cliArgs[1];
1546
+ if (command === '--version' || command === '-v') {
1547
+ printVersion();
1548
+ return;
1549
+ }
1439
1550
  if (!command) {
1440
1551
  printUsage();
1441
1552
  process.exit(1);
@@ -1549,6 +1660,10 @@ async function main() {
1549
1660
  await deleteDatasetRows();
1550
1661
  return;
1551
1662
  }
1663
+ if (command === 'datasets' && subcommand === 'delete') {
1664
+ await deleteDataset();
1665
+ return;
1666
+ }
1552
1667
  if (command === 'datasets' && subcommand === 'lock') {
1553
1668
  await lockDataset();
1554
1669
  return;
@@ -1564,7 +1679,9 @@ async function main() {
1564
1679
  printUsage();
1565
1680
  process.exit(1);
1566
1681
  }
1567
- main().catch(error => {
1568
- console.error(error instanceof Error ? error.message : 'Unknown error');
1569
- process.exit(1);
1570
- });
1682
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1683
+ main().catch(error => {
1684
+ console.error(sanitizeTerminalText(error instanceof Error ? error.message : 'Unknown error'));
1685
+ process.exit(1);
1686
+ });
1687
+ }