orizu 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { parseDatasetFile } from './file-parser.js';
11
11
  import { streamJsonlRowChunks } from './jsonl-stream.js';
12
12
  import { parseDatasetReference } from './dataset-download.js';
13
13
  import { parseGlobalFlags } from './global-flags.js';
14
- import { authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags } from './http.js';
14
+ import { assertSecureTokenTransport, authedFetch, getBaseUrl, resolveLoginBaseUrl, setGlobalFlags, } from './http.js';
15
15
  import { formatTaskCreateError } from './task-create-error.js';
16
16
  function printUsage() {
17
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>]`);
@@ -46,47 +46,81 @@ function createCodeVerifier() {
46
46
  function createCodeChallenge(verifier) {
47
47
  return createHash('sha256').update(verifier).digest('base64url');
48
48
  }
49
+ function sanitizeTerminalText(value) {
50
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
51
+ }
52
+ function validateBrowserUrl(url, expectedOrigin) {
53
+ let parsed;
54
+ try {
55
+ parsed = new URL(url);
56
+ }
57
+ catch {
58
+ throw new Error('Server returned an invalid browser URL.');
59
+ }
60
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
61
+ throw new Error('Server returned an unsupported browser URL scheme.');
62
+ }
63
+ if (parsed.username || parsed.password) {
64
+ throw new Error('Server returned a browser URL containing credentials.');
65
+ }
66
+ if (expectedOrigin && parsed.origin !== expectedOrigin) {
67
+ throw new Error('Server returned a browser URL for an unexpected origin.');
68
+ }
69
+ return parsed;
70
+ }
49
71
  function openInBrowser(url) {
72
+ const parsed = validateBrowserUrl(url);
50
73
  const platform = process.platform;
74
+ const href = parsed.href;
51
75
  if (platform === 'darwin') {
52
- spawn('open', [url], {
76
+ spawn('open', [href], {
53
77
  detached: true,
54
78
  stdio: 'ignore',
55
79
  }).unref();
56
80
  return;
57
81
  }
58
82
  if (platform === 'win32') {
59
- spawn('cmd', ['/c', 'start', '', url], {
83
+ spawn('rundll32.exe', ['url.dll,FileProtocolHandler', href], {
60
84
  detached: true,
61
85
  stdio: 'ignore',
62
86
  windowsHide: true,
63
87
  }).unref();
64
88
  return;
65
89
  }
66
- spawn('xdg-open', [url], {
90
+ spawn('xdg-open', [href], {
67
91
  detached: true,
68
92
  stdio: 'ignore',
69
93
  }).unref();
70
94
  }
71
95
  function formatTerminalLink(url) {
96
+ const safeUrl = sanitizeTerminalText(url);
97
+ try {
98
+ const parsed = validateBrowserUrl(safeUrl);
99
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
100
+ return safeUrl;
101
+ }
102
+ }
103
+ catch {
104
+ return safeUrl;
105
+ }
72
106
  if (!isInteractiveTerminal()) {
73
- return url;
107
+ return safeUrl;
74
108
  }
75
- return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`;
109
+ return `\u001B]8;;${safeUrl}\u0007${safeUrl}\u001B]8;;\u0007`;
76
110
  }
77
111
  async function parseJsonResponse(response, context) {
78
112
  const contentType = response.headers.get('content-type') || '';
79
113
  const rawBody = await response.text();
80
114
  if (!contentType.includes('application/json')) {
81
115
  throw new Error(`${context} returned non-JSON response (status ${response.status}). ` +
82
- `Body preview: ${rawBody.slice(0, 180)}`);
116
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
83
117
  }
84
118
  try {
85
119
  return JSON.parse(rawBody);
86
120
  }
87
121
  catch {
88
122
  throw new Error(`${context} returned invalid JSON (status ${response.status}). ` +
89
- `Body preview: ${rawBody.slice(0, 180)}`);
123
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
90
124
  }
91
125
  }
92
126
  async function promptSelect(title, items, label, options) {
@@ -99,9 +133,9 @@ async function promptSelect(title, items, label, options) {
99
133
  if (items.length === 1 && !options?.forcePrompt) {
100
134
  return items[0];
101
135
  }
102
- console.log(`\n${title}`);
136
+ console.log(`\n${sanitizeTerminalText(title)}`);
103
137
  items.forEach((item, index) => {
104
- console.log(` ${index + 1}. ${label(item, index)}`);
138
+ console.log(` ${index + 1}. ${sanitizeTerminalText(label(item, index))}`);
105
139
  });
106
140
  const rl = createInterface({ input, output });
107
141
  try {
@@ -187,7 +221,7 @@ async function resolveProjectSlug(projectArg) {
187
221
  const [teamSlug, projectSlug] = segments.map(normalizeSlugInput);
188
222
  const matchedTeam = teams.find(team => team.slug === teamSlug);
189
223
  if (!matchedTeam) {
190
- console.error(`Team '${teamSlug}' not found in your accessible teams.`);
224
+ console.error(`Team '${sanitizeTerminalText(teamSlug)}' not found in your accessible teams.`);
191
225
  const selectedTeam = await promptSelect('Select a team', teams, team => `${team.name} (${team.slug})`);
192
226
  const projects = await fetchProjects(selectedTeam.slug);
193
227
  const selectedProject = await promptSelect(`Select a project in ${selectedTeam.slug}`, projects, project => `${project.name} (${project.teamSlug}/${project.slug})`);
@@ -196,7 +230,7 @@ async function resolveProjectSlug(projectArg) {
196
230
  const projects = await fetchProjects(matchedTeam.slug);
197
231
  const matchedProject = projects.find(project => project.slug === projectSlug);
198
232
  if (!matchedProject) {
199
- console.error(`Project '${projectSlug}' not found in team '${matchedTeam.slug}'.`);
233
+ console.error(`Project '${sanitizeTerminalText(projectSlug)}' not found in team '${sanitizeTerminalText(matchedTeam.slug)}'.`);
200
234
  const selectedProject = await promptSelect(`Select a project in ${matchedTeam.slug}`, projects, project => `${project.name} (${project.teamSlug}/${project.slug})`);
201
235
  return `${selectedProject.teamSlug}/${selectedProject.slug}`;
202
236
  }
@@ -239,9 +273,9 @@ function printTeams(teams) {
239
273
  return;
240
274
  }
241
275
  const rows = teams.map(team => ({
242
- slug: team.slug,
243
- name: team.name || '-',
244
- role: team.role || '-',
276
+ slug: sanitizeTerminalText(team.slug),
277
+ name: sanitizeTerminalText(team.name || '-'),
278
+ role: sanitizeTerminalText(team.role || '-'),
245
279
  }));
246
280
  const slugWidth = Math.max('TEAM SLUG'.length, ...rows.map(row => row.slug.length));
247
281
  const nameWidth = Math.max('TEAM NAME'.length, ...rows.map(row => row.name.length));
@@ -258,9 +292,9 @@ function printProjects(projects) {
258
292
  return;
259
293
  }
260
294
  const rows = projects.map(project => ({
261
- project: `${project.teamSlug}/${project.slug}`,
262
- name: project.name || '-',
263
- role: project.role || '-',
295
+ project: sanitizeTerminalText(`${project.teamSlug}/${project.slug}`),
296
+ name: sanitizeTerminalText(project.name || '-'),
297
+ role: sanitizeTerminalText(project.role || '-'),
264
298
  }));
265
299
  const projectWidth = Math.max('TEAM/PROJECT'.length, ...rows.map(row => row.project.length));
266
300
  const nameWidth = Math.max('PROJECT NAME'.length, ...rows.map(row => row.name.length));
@@ -277,11 +311,11 @@ function printTasks(tasks) {
277
311
  return;
278
312
  }
279
313
  const rows = tasks.map(task => ({
280
- id: task.id,
281
- name: task.title || '-',
282
- status: task.status || '-',
314
+ id: sanitizeTerminalText(task.id),
315
+ name: sanitizeTerminalText(task.title || '-'),
316
+ status: sanitizeTerminalText(task.status || '-'),
283
317
  project: task.teamSlug && task.projectSlug
284
- ? `${task.teamSlug}/${task.projectSlug}`
318
+ ? sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)
285
319
  : 'unknown-project',
286
320
  }));
287
321
  const idWidth = Math.max('TASK ID'.length, ...rows.map(row => row.id.length));
@@ -299,8 +333,8 @@ function printApps(apps) {
299
333
  return;
300
334
  }
301
335
  const rows = apps.map(app => ({
302
- id: app.id,
303
- name: app.name || '-',
336
+ id: sanitizeTerminalText(app.id),
337
+ name: sanitizeTerminalText(app.name || '-'),
304
338
  version: `v${app.currentVersionNum || 1}`,
305
339
  }));
306
340
  const idWidth = Math.max('APP ID'.length, ...rows.map(row => row.id.length));
@@ -318,10 +352,10 @@ function printTeamMembers(members) {
318
352
  return;
319
353
  }
320
354
  const rows = members.map(member => ({
321
- id: member.id,
322
- userId: member.user_id || '-',
323
- email: member.email || '-',
324
- role: member.role || '-',
355
+ id: sanitizeTerminalText(member.id),
356
+ userId: sanitizeTerminalText(member.user_id || '-'),
357
+ email: sanitizeTerminalText(member.email || '-'),
358
+ role: sanitizeTerminalText(member.role || '-'),
325
359
  }));
326
360
  const idWidth = Math.max('MEMBER ID'.length, ...rows.map(row => row.id.length));
327
361
  const userIdWidth = Math.max('USER ID'.length, ...rows.map(row => row.userId.length));
@@ -335,16 +369,16 @@ function printTeamMembers(members) {
335
369
  }
336
370
  function printTaskStatusSummary(data) {
337
371
  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}`);
372
+ console.log(`Task: ${sanitizeTerminalText(task.title)} (${sanitizeTerminalText(task.id)})`);
373
+ console.log(`Status: ${sanitizeTerminalText(task.status)}`);
374
+ console.log(`Project: ${sanitizeTerminalText(`${task.teamSlug}/${task.projectSlug}`)}`);
341
375
  console.log(`Progress: ${task.progressPercentage}%`);
342
376
  console.log(`Counts: completed=${task.counts.completed}, in_progress=${task.counts.inProgress}, pending=${task.counts.pending}, skipped=${task.counts.skipped}`);
343
377
  console.log(`Required assignments: ${task.totalRequiredAssignments} (${task.datasetRowCount} rows x ${task.requiredAssignmentsPerRow})`);
344
378
  if (task.assignees.length > 0) {
345
379
  console.log('\nAssignees');
346
380
  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}`);
381
+ console.log(` ${sanitizeTerminalText(assignee.email)}: total=${assignee.total}, completed=${assignee.completed}, in_progress=${assignee.inProgress}, pending=${assignee.pending}, skipped=${assignee.skipped}`);
348
382
  });
349
383
  }
350
384
  }
@@ -469,6 +503,7 @@ function renderCliAuthBrowserPage(status) {
469
503
  }
470
504
  async function login() {
471
505
  const baseUrl = resolveLoginBaseUrl();
506
+ assertSecureTokenTransport(baseUrl);
472
507
  const codeVerifier = createCodeVerifier();
473
508
  const codeChallenge = createCodeChallenge(codeVerifier);
474
509
  const callbackPort = resolveAuthCallbackPort();
@@ -517,8 +552,9 @@ async function login() {
517
552
  return;
518
553
  }
519
554
  const { authorizeUrl } = await parseJsonResponse(response, 'CLI auth start');
520
- console.log(`Opening browser for login: ${authorizeUrl}`);
521
- openInBrowser(authorizeUrl);
555
+ const safeAuthorizeUrl = validateBrowserUrl(authorizeUrl, baseUrl).href;
556
+ console.log(`Opening browser for login: ${sanitizeTerminalText(safeAuthorizeUrl)}`);
557
+ openInBrowser(safeAuthorizeUrl);
522
558
  }
523
559
  catch (error) {
524
560
  server.close();
@@ -541,7 +577,7 @@ async function login() {
541
577
  refreshToken: loginData.refreshToken,
542
578
  expiresAt: loginData.expiresAt,
543
579
  });
544
- console.log(`Logged in as ${loginData.user.email ?? loginData.user.id}`);
580
+ console.log(`Logged in as ${sanitizeTerminalText(loginData.user.email ?? loginData.user.id)}`);
545
581
  }
546
582
  async function whoami() {
547
583
  const response = await authedFetch('/api/cli/auth/whoami');
@@ -549,25 +585,39 @@ async function whoami() {
549
585
  throw new Error(`whoami failed: ${await response.text()}`);
550
586
  }
551
587
  const data = await response.json();
552
- console.log(data.user.email ?? data.user.id);
588
+ console.log(sanitizeTerminalText(data.user.email ?? data.user.id));
553
589
  }
554
590
  async function logout() {
555
591
  const baseUrl = getBaseUrl();
556
592
  const credentials = getServerCredentials(baseUrl);
557
593
  if (!credentials) {
558
- console.log(`Already logged out for ${baseUrl}.`);
594
+ console.log(`Already logged out for ${sanitizeTerminalText(baseUrl)}.`);
559
595
  return;
560
596
  }
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);
597
+ let remoteLogoutError = null;
598
+ try {
599
+ assertSecureTokenTransport(baseUrl);
600
+ const response = await fetch(`${baseUrl}/api/cli/auth/logout`, {
601
+ method: 'POST',
602
+ headers: {
603
+ 'Content-Type': 'application/json',
604
+ Authorization: `Bearer ${credentials.accessToken}`,
605
+ },
606
+ body: JSON.stringify({ refreshToken: credentials.refreshToken }),
607
+ });
608
+ if (!response.ok) {
609
+ remoteLogoutError = sanitizeTerminalText(await response.text()).slice(0, 180);
610
+ }
611
+ }
612
+ catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ remoteLogoutError = sanitizeTerminalText(message).slice(0, 180);
615
+ }
569
616
  clearServerCredentials(baseUrl);
570
- console.log(`Logged out from ${baseUrl}.`);
617
+ if (remoteLogoutError) {
618
+ console.warn(`Warning: remote logout failed: ${remoteLogoutError}`);
619
+ }
620
+ console.log(`Logged out from ${sanitizeTerminalText(baseUrl)}.`);
571
621
  }
572
622
  async function listTeams() {
573
623
  printTeams(await fetchTeams());
@@ -602,7 +652,7 @@ async function createTeam() {
602
652
  throw new Error(`Failed to create team: ${await response.text()}`);
603
653
  }
604
654
  const data = await parseJsonResponse(response, 'Team create');
605
- console.log(`Created team: ${data.team.name} (${data.team.slug})`);
655
+ console.log(`Created team: ${sanitizeTerminalText(data.team.name)} (${sanitizeTerminalText(data.team.slug)})`);
606
656
  }
607
657
  async function listProjects() {
608
658
  const teamSlugArg = getArg('--team');
@@ -629,7 +679,7 @@ async function createProject() {
629
679
  throw new Error(`Failed to create project: ${await response.text()}`);
630
680
  }
631
681
  const data = await parseJsonResponse(response, 'Project create');
632
- console.log(`Created project ${data.project.teamSlug}/${data.project.slug}`);
682
+ console.log(`Created project ${sanitizeTerminalText(`${data.project.teamSlug}/${data.project.slug}`)}`);
633
683
  }
634
684
  async function listTasks() {
635
685
  const project = getArg('--project');
@@ -695,9 +745,9 @@ async function createAppFromFile() {
695
745
  throw new Error(`Failed to create app: ${await response.text()}`);
696
746
  }
697
747
  const data = await parseJsonResponse(response, 'App create');
698
- console.log(`Created app ${data.app.name} (${data.app.id}) v${data.app.versionNum}`);
748
+ console.log(`Created app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) v${data.app.versionNum}`);
699
749
  if (data.warnings?.length) {
700
- console.log(`Warnings: ${data.warnings.join('; ')}`);
750
+ console.log(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`);
701
751
  }
702
752
  }
703
753
  async function updateAppFromFile() {
@@ -731,9 +781,9 @@ async function updateAppFromFile() {
731
781
  throw new Error(`Failed to update app: ${await response.text()}`);
732
782
  }
733
783
  const data = await parseJsonResponse(response, 'App update');
734
- console.log(`Updated app ${data.app.name} (${data.app.id}) to v${data.app.versionNum}`);
784
+ console.log(`Updated app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) to v${data.app.versionNum}`);
735
785
  if (data.warnings?.length) {
736
- console.log(`Warnings: ${data.warnings.join('; ')}`);
786
+ console.log(`Warnings: ${sanitizeTerminalText(data.warnings.join('; '))}`);
737
787
  }
738
788
  }
739
789
  async function linkAppDataset() {
@@ -765,7 +815,7 @@ async function linkAppDataset() {
765
815
  throw new Error(`Failed to link dataset: ${await response.text()}`);
766
816
  }
767
817
  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}`);
818
+ console.log(`Linked dataset ${sanitizeTerminalText(data.linkedDataset.name)} (${sanitizeTerminalText(data.linkedDataset.id)}) to app ${sanitizeTerminalText(data.app.name)} (${sanitizeTerminalText(data.app.id)}) version ${data.versionNum}`);
769
819
  }
770
820
  function parseCommaSeparated(value) {
771
821
  if (!value) {
@@ -838,14 +888,14 @@ async function createTask() {
838
888
  }
839
889
  const baseUrl = getBaseUrl();
840
890
  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})` +
891
+ console.log(`Created task ${sanitizeTerminalText(data.task.title)} (${sanitizeTerminalText(data.task.id)}) [${sanitizeTerminalText(data.task.status)}]` +
892
+ `\n Task ID: ${sanitizeTerminalText(data.task.id)}` +
893
+ `\n Dataset ID: ${sanitizeTerminalText(datasetId)}` +
894
+ `\n Version: v${data.task.versionNum} (${sanitizeTerminalText(data.task.versionId)})` +
845
895
  `\n Labels/row: ${data.task.requiredAssignmentsPerRow}` +
846
896
  `\n Assignments: ${data.assignmentsCreated}` +
847
- (data.warning ? `\n Warning: ${data.warning}` : '') +
848
- `\n URL: ${taskUrl}`);
897
+ (data.warning ? `\n Warning: ${sanitizeTerminalText(data.warning)}` : '') +
898
+ `\n URL: ${sanitizeTerminalText(taskUrl)}`);
849
899
  }
850
900
  async function assignTask() {
851
901
  const taskId = getArg('--task');
@@ -922,7 +972,7 @@ async function updateTaskStatus(targetStatus) {
922
972
  }
923
973
  const data = await parseJsonResponse(response, 'Task status update');
924
974
  const action = targetStatus === 'paused' ? 'Paused' : 'Unpaused';
925
- console.log(`${action} task ${data.task.id} [${data.task.status}]`);
975
+ console.log(`${action} task ${sanitizeTerminalText(data.task.id)} [${sanitizeTerminalText(data.task.status)}]`);
926
976
  }
927
977
  async function appDetail() {
928
978
  const appId = getArg('--app');
@@ -945,10 +995,10 @@ async function appDetail() {
945
995
  console.log(JSON.stringify(detail, null, 2));
946
996
  return;
947
997
  }
948
- console.log(`App: ${detail.name} (${detail.id})`);
949
- console.log(` Project: ${detail.teamSlug}/${detail.projectSlug}`);
998
+ console.log(`App: ${sanitizeTerminalText(detail.name)} (${sanitizeTerminalText(detail.id)})`);
999
+ console.log(` Project: ${sanitizeTerminalText(`${detail.teamSlug}/${detail.projectSlug}`)}`);
950
1000
  if (detail.currentVersion) {
951
- console.log(` Current version: v${detail.currentVersion.versionNum} (${detail.currentVersion.versionId})`);
1001
+ console.log(` Current version: v${detail.currentVersion.versionNum} (${sanitizeTerminalText(detail.currentVersion.versionId)})`);
952
1002
  console.log(` Input schema: ${detail.currentVersion.inputJsonSchema ? 'defined' : 'none'}`);
953
1003
  console.log(` Output schema: ${detail.currentVersion.outputJsonSchema ? 'defined' : 'none'}`);
954
1004
  }
@@ -957,10 +1007,10 @@ async function appDetail() {
957
1007
  }
958
1008
  console.log(` Compatible datasets: ${detail.compatibleDatasetsCount}/${detail.totalDatasetsCount}`);
959
1009
  if (detail.createdByEmail) {
960
- console.log(` Created by: ${detail.createdByName || detail.createdByEmail}`);
1010
+ console.log(` Created by: ${sanitizeTerminalText(detail.createdByName || detail.createdByEmail)}`);
961
1011
  }
962
- console.log(` Created: ${detail.createdAt}`);
963
- console.log(` Updated: ${detail.updatedAt}`);
1012
+ console.log(` Created: ${sanitizeTerminalText(detail.createdAt)}`);
1013
+ console.log(` Updated: ${sanitizeTerminalText(detail.updatedAt)}`);
964
1014
  }
965
1015
  async function listTeamMembers() {
966
1016
  const teamSlug = await resolveTeamSlug(getArg('--team'));
@@ -981,7 +1031,7 @@ async function addTeamMember() {
981
1031
  throw new Error(`Failed to add team member: ${await response.text()}`);
982
1032
  }
983
1033
  const data = await parseJsonResponse(response, 'Team member add');
984
- console.log(`Added team member ${data.member.email} (${data.member.id})`);
1034
+ console.log(`Added team member ${sanitizeTerminalText(data.member.email)} (${sanitizeTerminalText(data.member.id)})`);
985
1035
  }
986
1036
  async function removeTeamMember() {
987
1037
  const teamSlug = await resolveTeamSlug(getArg('--team'));
@@ -998,7 +1048,7 @@ async function removeTeamMember() {
998
1048
  if (!response.ok) {
999
1049
  throw new Error(`Failed to remove team member: ${await response.text()}`);
1000
1050
  }
1001
- console.log(`Removed team member ${member.email}`);
1051
+ console.log(`Removed team member ${sanitizeTerminalText(member.email)}`);
1002
1052
  }
1003
1053
  async function changeTeamMemberRole() {
1004
1054
  const teamSlugArg = getArg('--team');
@@ -1024,7 +1074,7 @@ async function changeTeamMemberRole() {
1024
1074
  if (!response.ok) {
1025
1075
  throw new Error(`Failed to update member role: ${await response.text()}`);
1026
1076
  }
1027
- console.log(`Updated ${member.email} role to ${role}`);
1077
+ console.log(`Updated ${sanitizeTerminalText(member.email)} role to ${sanitizeTerminalText(role)}`);
1028
1078
  }
1029
1079
  async function createDatasetFromRows(project, name, sourceType, rows) {
1030
1080
  const response = await authedFetch('/api/cli/datasets/upload', {
@@ -1061,7 +1111,7 @@ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
1061
1111
  throw new Error(message);
1062
1112
  }
1063
1113
  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. ` +
1114
+ `Dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) was created and ${totalUploaded} rows were uploaded. ` +
1065
1115
  `Fix the file, remove the first ${totalUploaded} rows, and run ` +
1066
1116
  `orizu datasets append --dataset ${dataset.id} --file <remaining-file>.`);
1067
1117
  }
@@ -1099,7 +1149,7 @@ async function uploadJsonlDatasetInChunks(file, project, datasetName) {
1099
1149
  if (!dataset) {
1100
1150
  throw new Error('Dataset file contains no rows');
1101
1151
  }
1102
- console.log(`Uploaded dataset ${dataset.name} (${dataset.id}) with ${dataset.rowCount} rows.`);
1152
+ console.log(`Uploaded dataset ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}) with ${dataset.rowCount} rows.`);
1103
1153
  if (dataset.url) {
1104
1154
  console.log(`View dataset: ${formatTerminalLink(dataset.url)}`);
1105
1155
  }
@@ -1120,7 +1170,7 @@ async function uploadDataset() {
1120
1170
  }
1121
1171
  const { rows, sourceType } = parseDatasetFile(file);
1122
1172
  const data = await createDatasetFromRows(project, datasetName, sourceType, rows);
1123
- console.log(`Uploaded dataset ${data.dataset.name} (${data.dataset.id}) with ${data.dataset.rowCount} rows.`);
1173
+ console.log(`Uploaded dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) with ${data.dataset.rowCount} rows.`);
1124
1174
  if (data.dataset.url) {
1125
1175
  console.log(`View dataset: ${formatTerminalLink(data.dataset.url)}`);
1126
1176
  }
@@ -1161,7 +1211,7 @@ async function downloadDataset() {
1161
1211
  : `${datasetId}.${format}`;
1162
1212
  const bytes = new Uint8Array(await response.arrayBuffer());
1163
1213
  writeFileSync(filename, bytes);
1164
- console.log(`Saved dataset ${datasetId} (${format.toUpperCase()}) to ${filename}`);
1214
+ console.log(`Saved dataset ${sanitizeTerminalText(datasetId)} (${format.toUpperCase()}) to ${sanitizeTerminalText(filename)}`);
1165
1215
  }
1166
1216
  const MAX_INPUT_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
1167
1217
  const APPEND_CHUNK_SIZE_ROWS = 500;
@@ -1213,7 +1263,7 @@ async function appendJsonlDatasetRowsInChunks(datasetId, file) {
1213
1263
  if (!lastResult) {
1214
1264
  throw new Error('Dataset append file must contain at least one row');
1215
1265
  }
1216
- console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1266
+ console.log(`Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`);
1217
1267
  }
1218
1268
  async function appendDatasetRows() {
1219
1269
  const projectArg = getArg('--project');
@@ -1262,7 +1312,7 @@ async function appendDatasetRows() {
1262
1312
  }
1263
1313
  if (rows.length <= APPEND_CHUNK_SIZE_ROWS) {
1264
1314
  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}`);
1315
+ console.log(`Appended ${data.appendedCount} rows to dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`);
1266
1316
  return;
1267
1317
  }
1268
1318
  // Chunked upload for large row counts (ALI-555: track partial progress)
@@ -1286,7 +1336,7 @@ async function appendDatasetRows() {
1286
1336
  }
1287
1337
  }
1288
1338
  if (lastResult) {
1289
- console.log(`Appended ${totalAppended} rows to dataset ${lastResult.dataset.name} (${lastResult.dataset.id}). New row count: ${lastResult.dataset.rowCount}`);
1339
+ console.log(`Appended ${totalAppended} rows to dataset ${sanitizeTerminalText(lastResult.dataset.name)} (${sanitizeTerminalText(lastResult.dataset.id)}). New row count: ${lastResult.dataset.rowCount}`);
1290
1340
  }
1291
1341
  }
1292
1342
  async function editDatasetRows() {
@@ -1332,7 +1382,7 @@ async function editDatasetRows() {
1332
1382
  throw new Error(`Edit rows failed: ${await response.text()}`);
1333
1383
  }
1334
1384
  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}`);
1385
+ console.log(`Updated ${data.updatedCount} rows in dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Current row count: ${data.dataset.rowCount}`);
1336
1386
  }
1337
1387
  async function deleteDatasetRows() {
1338
1388
  const projectArg = getArg('--project');
@@ -1360,7 +1410,7 @@ async function deleteDatasetRows() {
1360
1410
  throw new Error(`Delete rows failed: ${await response.text()}`);
1361
1411
  }
1362
1412
  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}`);
1413
+ console.log(`Deleted ${data.deletedCount} rows from dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). New row count: ${data.dataset.rowCount}`);
1364
1414
  }
1365
1415
  async function lockDataset() {
1366
1416
  const projectArg = getArg('--project');
@@ -1383,7 +1433,7 @@ async function lockDataset() {
1383
1433
  throw new Error(`Lock failed: ${await response.text()}`);
1384
1434
  }
1385
1435
  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}`);
1436
+ console.log(`Locked dataset ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}) at ${sanitizeTerminalText(data.dataset.lockedAt)}. Row count: ${data.dataset.rowCount}`);
1387
1437
  }
1388
1438
  async function cloneDataset() {
1389
1439
  const projectArg = getArg('--project');
@@ -1406,7 +1456,7 @@ async function cloneDataset() {
1406
1456
  throw new Error(`Clone failed: ${await response.text()}`);
1407
1457
  }
1408
1458
  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}`);
1459
+ console.log(`Cloned dataset ${sanitizeTerminalText(data.dataset.parentDatasetId)} -> ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Row count: ${data.dataset.rowCount}`);
1410
1460
  }
1411
1461
  async function downloadAnnotations() {
1412
1462
  let taskId = getArg('--task');
@@ -1428,7 +1478,7 @@ async function downloadAnnotations() {
1428
1478
  : fallbackName;
1429
1479
  const bytes = new Uint8Array(await response.arrayBuffer());
1430
1480
  writeFileSync(filename, bytes);
1431
- console.log(`Saved ${format.toUpperCase()} export to ${filename}`);
1481
+ console.log(`Saved ${format.toUpperCase()} export to ${sanitizeTerminalText(filename)}`);
1432
1482
  }
1433
1483
  async function main() {
1434
1484
  const parsed = parseGlobalFlags(process.argv.slice(2));
@@ -1565,6 +1615,6 @@ async function main() {
1565
1615
  process.exit(1);
1566
1616
  }
1567
1617
  main().catch(error => {
1568
- console.error(error instanceof Error ? error.message : 'Unknown error');
1618
+ console.error(sanitizeTerminalText(error instanceof Error ? error.message : 'Unknown error'));
1569
1619
  process.exit(1);
1570
1620
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,18 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'fs'
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from 'fs'
2
13
  import { join } from 'path'
3
14
  import { homedir } from 'os'
15
+ import { randomBytes } from 'crypto'
4
16
  import { ServerCredentials, StoredCredentialsV1, StoredCredentialsV2 } from './types.js'
5
17
 
6
18
  function getConfigDir(): string {
@@ -53,9 +65,36 @@ function migrateToV2(stored: StoredCredentialsV1): StoredCredentialsV2 {
53
65
 
54
66
  function writeCredentials(config: StoredCredentialsV2) {
55
67
  const dir = getConfigDir()
56
- mkdirSync(dir, { recursive: true })
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
69
+ chmodSync(dir, 0o700)
70
+
57
71
  const path = getCredentialsPath()
58
- writeFileSync(path, JSON.stringify(config, null, 2), 'utf-8')
72
+ const tempPath = join(
73
+ dir,
74
+ `.credentials.json.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
75
+ )
76
+ const payload = JSON.stringify(config, null, 2) + '\n'
77
+ const fd = openSync(tempPath, 'wx', 0o600)
78
+
79
+ try {
80
+ writeFileSync(fd, payload, 'utf-8')
81
+ fsyncSync(fd)
82
+ } finally {
83
+ closeSync(fd)
84
+ }
85
+
86
+ chmodSync(tempPath, 0o600)
87
+ try {
88
+ renameSync(tempPath, path)
89
+ } catch (error: any) {
90
+ if (process.platform === 'win32' && error?.code === 'EEXIST') {
91
+ rmSync(path, { force: true })
92
+ renameSync(tempPath, path)
93
+ } else {
94
+ rmSync(tempPath, { force: true })
95
+ throw error
96
+ }
97
+ }
59
98
  chmodSync(path, 0o600)
60
99
  }
61
100
 
package/src/http.ts CHANGED
@@ -45,12 +45,43 @@ export function getBaseUrl(): string {
45
45
  return resolveBaseUrl()
46
46
  }
47
47
 
48
+ function isLoopbackHostname(hostname: string): boolean {
49
+ const normalized = hostname.toLowerCase()
50
+ return (
51
+ normalized === 'localhost' ||
52
+ normalized === '[::1]' ||
53
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
54
+ )
55
+ }
56
+
57
+ export function assertSecureTokenTransport(baseUrl: string) {
58
+ let parsed: URL
59
+ try {
60
+ parsed = new URL(baseUrl)
61
+ } catch {
62
+ throw new Error(`Invalid server URL: '${baseUrl}'`)
63
+ }
64
+
65
+ if (parsed.protocol === 'https:') {
66
+ return
67
+ }
68
+
69
+ if (parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)) {
70
+ return
71
+ }
72
+
73
+ throw new Error(
74
+ `Refusing to send CLI tokens to ${baseUrl}. Use HTTPS, or --local for loopback development.`
75
+ )
76
+ }
77
+
48
78
  function isExpired(expiresAt: number): boolean {
49
79
  const nowUnix = Math.floor(Date.now() / 1000)
50
80
  return expiresAt <= nowUnix + 30
51
81
  }
52
82
 
53
83
  async function refreshCredentials(baseUrl: string, credentials: ServerCredentials): Promise<ServerCredentials> {
84
+ assertSecureTokenTransport(baseUrl)
54
85
  const response = await fetch(`${baseUrl}/api/cli/auth/refresh`, {
55
86
  method: 'POST',
56
87
  headers: { 'Content-Type': 'application/json' },
@@ -73,6 +104,7 @@ async function refreshCredentials(baseUrl: string, credentials: ServerCredential
73
104
 
74
105
  export async function authedFetch(path: string, init: RequestInit = {}) {
75
106
  const baseUrl = resolveBaseUrl()
107
+ assertSecureTokenTransport(baseUrl)
76
108
  const credentials = getServerCredentials(baseUrl)
77
109
  if (!credentials) {
78
110
  throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)