feedbackbasket-cli 3.0.0 → 3.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.
@@ -1,8 +1,9 @@
1
1
  interface LoginResult {
2
2
  token: string;
3
- scope: 'read' | 'full';
3
+ scope: "read" | "full";
4
4
  }
5
+ export declare function validateReturnedScope(value: string | null, maximum: "read" | "full"): "read" | "full";
5
6
  export declare function isCliTokenFormat(token: string): boolean;
6
- export declare function browserLogin(baseUrl: string, scope?: 'read' | 'full'): Promise<LoginResult>;
7
- export declare function manualLogin(baseUrl: string, scope?: 'read' | 'full'): Promise<LoginResult>;
7
+ export declare function browserLogin(baseUrl: string, scope?: "read" | "full"): Promise<LoginResult>;
8
+ export declare function manualLogin(baseUrl: string, scope?: "read" | "full"): Promise<LoginResult>;
8
9
  export {};
@@ -1,10 +1,19 @@
1
- import { createInterface } from 'node:readline/promises';
2
- import { stdin as input, stdout as output } from 'node:process';
3
- import { createServer } from 'node:http';
4
- import { randomUUID } from 'node:crypto';
5
- import { URL } from 'node:url';
6
- import open from 'open';
7
- import { brand, logo } from '../output/theme.js';
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { createServer, } from "node:http";
4
+ import { randomUUID } from "node:crypto";
5
+ import { URL } from "node:url";
6
+ import open from "open";
7
+ import { brand, logo } from "../output/theme.js";
8
+ export function validateReturnedScope(value, maximum) {
9
+ if (value !== "read" && value !== "full") {
10
+ throw new Error("The authorization response did not include a valid access level");
11
+ }
12
+ if (maximum === "read" && value === "full") {
13
+ throw new Error("The authorization response exceeds the requested access maximum");
14
+ }
15
+ return value;
16
+ }
8
17
  const TIMEOUT_MS = 120_000;
9
18
  const CLI_TOKEN_PATTERN = /^fb_cli_[a-f0-9]{64}$/;
10
19
  export function isCliTokenFormat(token) {
@@ -38,30 +47,31 @@ const ERROR_HTML = (message) => `<!DOCTYPE html>
38
47
  <h1>Authentication Failed</h1>
39
48
  <p>${message}</p>
40
49
  </div></body></html>`;
41
- export async function browserLogin(baseUrl, scope = 'read') {
50
+ export async function browserLogin(baseUrl, scope = "read") {
42
51
  const state = randomUUID();
43
52
  return new Promise((resolve, reject) => {
44
53
  const server = createServer((req, res) => {
45
- const url = new URL(req.url ?? '/', `http://127.0.0.1`);
46
- if (url.pathname !== '/callback') {
54
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
55
+ if (url.pathname !== "/callback") {
47
56
  res.writeHead(404);
48
- res.end('Not found');
57
+ res.end("Not found");
49
58
  return;
50
59
  }
51
- const receivedState = url.searchParams.get('state');
52
- const token = url.searchParams.get('token');
53
- const error = url.searchParams.get('error');
60
+ const receivedState = url.searchParams.get("state");
61
+ const token = url.searchParams.get("token");
62
+ const returnedScope = url.searchParams.get("scope");
63
+ const error = url.searchParams.get("error");
54
64
  // CSRF validation
55
65
  if (receivedState !== state) {
56
- res.writeHead(400, { 'Content-Type': 'text/html' });
57
- res.end(ERROR_HTML('State mismatch — possible CSRF attack. Please try again.'));
66
+ res.writeHead(400, { "Content-Type": "text/html" });
67
+ res.end(ERROR_HTML("State mismatch — possible CSRF attack. Please try again."));
58
68
  cleanup();
59
- reject(new Error('State mismatch during authentication'));
69
+ reject(new Error("State mismatch during authentication"));
60
70
  return;
61
71
  }
62
72
  // User denied
63
73
  if (error) {
64
- res.writeHead(200, { 'Content-Type': 'text/html' });
74
+ res.writeHead(200, { "Content-Type": "text/html" });
65
75
  res.end(ERROR_HTML(`Authorization was denied: ${error}`));
66
76
  cleanup();
67
77
  reject(new Error(`Authorization denied: ${error}`));
@@ -69,32 +79,44 @@ export async function browserLogin(baseUrl, scope = 'read') {
69
79
  }
70
80
  // Missing token
71
81
  if (!token) {
72
- res.writeHead(400, { 'Content-Type': 'text/html' });
73
- res.end(ERROR_HTML('No token received. Please try again.'));
82
+ res.writeHead(400, { "Content-Type": "text/html" });
83
+ res.end(ERROR_HTML("No token received. Please try again."));
84
+ cleanup();
85
+ reject(new Error("No token received in callback"));
86
+ return;
87
+ }
88
+ let selectedScope;
89
+ try {
90
+ selectedScope = validateReturnedScope(returnedScope, scope);
91
+ }
92
+ catch (cause) {
93
+ const message = cause instanceof Error ? cause.message : "Invalid access level";
94
+ res.writeHead(400, { "Content-Type": "text/html" });
95
+ res.end(ERROR_HTML(message));
74
96
  cleanup();
75
- reject(new Error('No token received in callback'));
97
+ reject(new Error(message));
76
98
  return;
77
99
  }
78
100
  // Success
79
- res.writeHead(200, { 'Content-Type': 'text/html' });
101
+ res.writeHead(200, { "Content-Type": "text/html" });
80
102
  res.end(SUCCESS_HTML);
81
103
  cleanup();
82
- resolve({ token, scope });
104
+ resolve({ token, scope: selectedScope });
83
105
  });
84
106
  const timeout = setTimeout(() => {
85
107
  cleanup();
86
- reject(new Error('Authentication timed out after 2 minutes'));
108
+ reject(new Error("Authentication timed out after 2 minutes"));
87
109
  }, TIMEOUT_MS);
88
110
  function cleanup() {
89
111
  clearTimeout(timeout);
90
112
  server.close();
91
113
  }
92
114
  // Listen on random port on loopback
93
- server.listen(0, '127.0.0.1', () => {
115
+ server.listen(0, "127.0.0.1", () => {
94
116
  const addr = server.address();
95
- if (!addr || typeof addr === 'string') {
117
+ if (!addr || typeof addr === "string") {
96
118
  cleanup();
97
- reject(new Error('Failed to start local auth server'));
119
+ reject(new Error("Failed to start local auth server"));
98
120
  return;
99
121
  }
100
122
  const port = addr.port;
@@ -102,14 +124,14 @@ export async function browserLogin(baseUrl, scope = 'read') {
102
124
  console.log();
103
125
  console.log(` ${logo()} CLI`);
104
126
  console.log();
105
- console.log(` ${brand.primary('Opening browser for authentication...')}`);
127
+ console.log(` ${brand.primary("Opening browser for authentication...")}`);
106
128
  console.log();
107
- console.log(` ${brand.muted('If the browser doesn\'t open, visit:')}`);
129
+ console.log(` ${brand.muted("If the browser doesn't open, visit:")}`);
108
130
  console.log(` ${brand.primary(authorizeUrl)}`);
109
131
  console.log();
110
- console.log(` ${brand.muted('Waiting for authentication...')}`);
111
- console.log(` ${brand.muted('If this machine cannot receive the localhost browser callback, use:')}`);
112
- console.log(` ${brand.command('feedbackbasket login --manual')}`);
132
+ console.log(` ${brand.muted("Waiting for authentication...")}`);
133
+ console.log(` ${brand.muted("If this machine cannot receive the localhost browser callback, use:")}`);
134
+ console.log(` ${brand.command("feedbackbasket login --manual")}`);
113
135
  console.log();
114
136
  open(authorizeUrl).catch(() => {
115
137
  // Browser open failed; user will use the URL manually.
@@ -117,33 +139,38 @@ export async function browserLogin(baseUrl, scope = 'read') {
117
139
  });
118
140
  });
119
141
  }
120
- export async function manualLogin(baseUrl, scope = 'read') {
142
+ export async function manualLogin(baseUrl, scope = "read") {
121
143
  const authorizeUrl = `${baseUrl}/cli/authorize?mode=manual&scope=${scope}`;
122
144
  console.log();
123
145
  console.log(` ${logo()} CLI`);
124
146
  console.log();
125
- console.log(` ${brand.primary('Open this URL on any machine with a browser:')}`);
147
+ console.log(` ${brand.primary("Open this URL on any machine with a browser:")}`);
126
148
  console.log();
127
149
  console.log(` ${brand.primary(authorizeUrl)}`);
128
150
  console.log();
129
- console.log(` ${brand.muted('Use this when a remote server cannot receive the localhost browser callback.')}`);
130
- console.log(` ${brand.muted('The CLI still needs outbound HTTPS access to verify and use the token.')}`);
151
+ console.log(` ${brand.muted("Use this when a remote server cannot receive the localhost browser callback.")}`);
152
+ console.log(` ${brand.muted("The CLI still needs outbound HTTPS access to verify and use the token.")}`);
131
153
  console.log();
132
- console.log(` ${brand.muted('After approving access, paste the token shown in your browser.')}`);
154
+ console.log(` ${brand.muted("After approving access, paste the token shown in your browser.")}`);
133
155
  console.log();
134
156
  open(authorizeUrl).catch(() => {
135
157
  // Browser open failed; user will use the URL manually.
136
158
  });
137
159
  const rl = createInterface({ input, output });
138
160
  try {
139
- const token = (await rl.question(' Paste token: ')).trim();
140
- if (!token) {
141
- throw new Error('No token provided');
161
+ const rawResult = (await rl.question(" Paste authorization result: ")).trim();
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(rawResult);
165
+ }
166
+ catch {
167
+ throw new Error("Expected the authorization result copied from the browser");
142
168
  }
143
- if (!isCliTokenFormat(token)) {
144
- throw new Error('Expected a FeedbackBasket CLI token beginning with fb_cli_');
169
+ if (typeof parsed.token !== "string" || !isCliTokenFormat(parsed.token)) {
170
+ throw new Error("Expected a FeedbackBasket CLI token beginning with fb_cli_");
145
171
  }
146
- return { token, scope };
172
+ const selectedScope = validateReturnedScope(typeof parsed.scope === "string" ? parsed.scope : null, scope);
173
+ return { token: parsed.token, scope: selectedScope };
147
174
  }
148
175
  finally {
149
176
  rl.close();
@@ -4,6 +4,10 @@ export declare const CLI_CAPABILITIES: readonly {
4
4
  commands: readonly string[];
5
5
  }[];
6
6
  export declare const CLI_EXEMPTIONS: ({
7
+ readonly id: "browser-oauth";
8
+ readonly surface: "mcp-http";
9
+ readonly reason: "Browser OAuth is a Streamable HTTP transport capability; STDIO uses environment credentials and the CLI keeps its own login flow.";
10
+ } | {
7
11
  readonly id: "auth";
8
12
  readonly surface: "cli";
9
13
  readonly reason: "Authentication is local CLI credential management, not a product operation.";
@@ -71,6 +71,8 @@ export declare class FeedbackBasketClient {
71
71
  status?: string;
72
72
  category?: string;
73
73
  sentiment?: string;
74
+ closeReason?: string;
75
+ closeNote?: string;
74
76
  }): Promise<Feedback>;
75
77
  createNote(feedbackId: string, content: string): Promise<{
76
78
  id: string;
@@ -96,9 +98,13 @@ export declare class FeedbackBasketClient {
96
98
  deleted: boolean;
97
99
  id: string;
98
100
  }>;
99
- bulkUpdateStatus(ids: string[], status: string): Promise<{
101
+ bulkUpdateStatus(ids: string[], status: string, closure?: {
102
+ closeReason?: string;
103
+ closeNote?: string;
104
+ }): Promise<{
100
105
  updated: number;
101
106
  status: string;
107
+ closeReason?: string | null;
102
108
  }>;
103
109
  updateNote(feedbackId: string, noteId: string, content: string): Promise<{
104
110
  id: string;
@@ -99,8 +99,12 @@ export class FeedbackBasketClient {
99
99
  async deleteFeedback(id) {
100
100
  return this.request('DELETE', `/feedback/${encodeURIComponent(id)}`);
101
101
  }
102
- async bulkUpdateStatus(ids, status) {
103
- return this.request('POST', '/feedback/bulk-update', { ids, status });
102
+ async bulkUpdateStatus(ids, status, closure = {}) {
103
+ return this.request('POST', '/feedback/bulk-update', {
104
+ ids,
105
+ status,
106
+ ...closure,
107
+ });
104
108
  }
105
109
  async updateNote(feedbackId, noteId, content) {
106
110
  return this.request('PATCH', `/feedback/${encodeURIComponent(feedbackId)}/notes/${encodeURIComponent(noteId)}`, { content });
@@ -117,7 +121,9 @@ export class FeedbackBasketClient {
117
121
  return this.request('GET', '/team');
118
122
  }
119
123
  async updateMemberRole(memberId, role) {
120
- return this.request('PATCH', `/team/${encodeURIComponent(memberId)}`, { role });
124
+ return this.request('PATCH', `/team/${encodeURIComponent(memberId)}`, {
125
+ role,
126
+ });
121
127
  }
122
128
  async removeMember(memberId) {
123
129
  return this.request('DELETE', `/team/${encodeURIComponent(memberId)}`);
@@ -130,24 +136,27 @@ export class FeedbackBasketClient {
130
136
  method,
131
137
  signal: controller.signal,
132
138
  headers: {
133
- 'Authorization': `Bearer ${this.token}`,
139
+ Authorization: `Bearer ${this.token}`,
134
140
  'Content-Type': 'application/json',
135
141
  'User-Agent': USER_AGENT,
136
142
  },
137
143
  body: data === undefined ? undefined : JSON.stringify(data),
138
144
  });
139
145
  const contentType = response.headers.get('content-type') ?? '';
140
- const payload = contentType.includes('application/json')
141
- ? await response.json().catch(() => null)
142
- : await response.text();
146
+ const payload = contentType.includes('application/json') ? await response.json().catch(() => null) : await response.text();
143
147
  if (!response.ok) {
144
148
  const message = getErrorMessage(payload, response.statusText);
145
149
  switch (response.status) {
146
- case 401: throw errAuth(message);
147
- case 403: throw errForbidden(message);
148
- case 404: throw errAPI(404, message);
149
- case 429: throw errRateLimit();
150
- default: throw errAPI(response.status, message);
150
+ case 401:
151
+ throw errAuth(message);
152
+ case 403:
153
+ throw errForbidden(message);
154
+ case 404:
155
+ throw errAPI(404, message);
156
+ case 429:
157
+ throw errRateLimit();
158
+ default:
159
+ throw errAPI(response.status, message);
151
160
  }
152
161
  }
153
162
  return payload;
@@ -1,6 +1,12 @@
1
- import { Command } from 'commander';
2
- import type { OutputWriter } from '../output/writer.js';
1
+ import { Command } from "commander";
2
+ import type { OutputWriter } from "../output/writer.js";
3
+ export declare function authLoginResult(email: string | undefined, scope: "read" | "full", defaultProject: string | undefined): {
4
+ authenticated: boolean;
5
+ email: string | undefined;
6
+ scope: "read" | "full";
7
+ defaultProject: string | undefined;
8
+ };
3
9
  export declare function createAuthCommand(getWriter: () => OutputWriter): Command;
4
- export declare function resolveAuthScope(value: string): 'read' | 'full';
10
+ export declare function resolveAuthScope(value: string): "read" | "full";
5
11
  export declare function createLoginCommand(getWriter: () => OutputWriter): Command;
6
12
  export declare function createLogoutCommand(getWriter: () => OutputWriter): Command;