feedbackbasket-cli 0.9.3 → 0.11.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/README.md CHANGED
@@ -39,6 +39,7 @@ feedbackbasket feedback list --category BUG --agent
39
39
  feedbackbasket feedback create "Login button is broken" --content "Clicking Log in does nothing in Safari." --project myapp --type bug --agent
40
40
  feedbackbasket feedback update <id> --status PLANNED --agent
41
41
  feedbackbasket widget script myproject --agent
42
+ feedbackbasket mobile setup myproject --bundle-id com.example.app --include-publishable-key --agent
42
43
  ```
43
44
 
44
45
  When installing or configuring a widget for the current app, agents should not rely on the CLI default project. First run `feedbackbasket projects list --agent`, match the current app by its real website URL or clearly matching project name, and only create a new project after confirming no existing project belongs to this app. If the only known URL is `localhost`, ask for the production, staging, preview, or intended public URL before creating the project.
@@ -100,6 +101,7 @@ feedbackbasket feedback update <id> --status PLANNED # Update status
100
101
  feedbackbasket feedback update <id> --category BUG # Update category
101
102
  feedbackbasket feedback reply <id> "Thanks!" --delivery email --reply-to support@example.com
102
103
  feedbackbasket feedback reply <id> "Thanks!" --delivery widget
104
+ feedbackbasket feedback reply <id> "Thanks!" --delivery in-app
103
105
  feedbackbasket feedback reply <id> "Thanks!" --delivery both --reply-to support@example.com
104
106
  feedbackbasket feedback replies <id> # List sent replies
105
107
  feedbackbasket feedback note <id> "Investigating this..." # Add internal note
@@ -129,12 +131,15 @@ feedbackbasket bugs stats --project myapp # Per-project stats
129
131
  feedbackbasket widget settings myapp
130
132
 
131
133
  # Update widget configuration
134
+ feedbackbasket widget settings myapp --capture-mode waitlist
135
+ feedbackbasket widget settings myapp --capture-mode feedback
132
136
  feedbackbasket widget settings myapp --color "#22c55e" --label "Send Feedback"
133
137
  feedbackbasket widget settings myapp --position bottom-left --display modal
134
138
  feedbackbasket widget settings myapp --email-required --intro "How can we improve?"
135
139
  feedbackbasket widget settings myapp --button-radius 10 --button-size regular
136
140
  feedbackbasket widget settings myapp --show-email --allow-attachments
137
141
  feedbackbasket widget settings myapp --email-read-only --hide-email-when-prefilled
142
+ feedbackbasket widget settings myapp --error-tracking --allow-console-errors
138
143
 
139
144
  # Configure guided feedback types and follow-up questions
140
145
  feedbackbasket widget flow myapp
@@ -146,6 +151,19 @@ feedbackbasket widget flow myapp --config ./feedback-flow.json
146
151
  feedbackbasket widget script myapp
147
152
  ```
148
153
 
154
+ Waitlist mode uses the same project script. Add `data-feedbackbasket-waitlist` to your own form, with a required `email` field and optional `name` field. The CLI's `widget script` output shows a starter form when waitlist mode is active.
155
+
156
+ ### Waitlist
157
+
158
+ ```bash
159
+ feedbackbasket waitlist list myapp
160
+ feedbackbasket waitlist list myapp --search "@example.com" --limit 50 --offset 0
161
+ feedbackbasket waitlist list myapp --agent
162
+ feedbackbasket waitlist export myapp
163
+ ```
164
+
165
+ Waitlist listing returns emails, optional names, source pages, total counts, the active capture mode, and pagination. Export prints the dashboard-compatible CSV to stdout.
166
+
149
167
  For inline trigger mode, load the widget once and call the public API from your own button:
150
168
 
151
169
  ```html
@@ -156,6 +174,8 @@ For inline trigger mode, load the widget once and call the public API from your
156
174
 
157
175
  Passing the trigger element lets popup mode open beside your custom button. Calling `window.FeedbackWidget.openFeedbackForm()` with no arguments still uses the configured widget position.
158
176
 
177
+ Use only the public `openFeedbackForm()` API from the snippet. Do not call internal or undocumented methods such as `open()` or `openModal()`.
178
+
159
179
  Use `--email-read-only` and `--hide-email-when-prefilled` with runtime `userEmail` values from your app. These settings do not store visitor emails in FeedbackBasket widget settings.
160
180
 
161
181
  The default widget experience is a basic modal. Only switch to popup mode or enable guided feedback when you intentionally want that flow.
@@ -184,6 +204,34 @@ The default widget experience is a basic modal. Only switch to popup mode or ena
184
204
  }
185
205
  ```
186
206
 
207
+ ### Mobile Apps
208
+
209
+ Mobile setup is additive and does not change the website widget. The `fb_mobile_` value is a publishable, write-only project identifier designed to ship in an app; it is not a CLI token or private API key. Mobile commands mask it unless `--include-publishable-key` is explicitly supplied.
210
+
211
+ ```bash
212
+ # Enable mobile feedback and add allowed iOS bundle IDs
213
+ feedbackbasket mobile setup myapp --bundle-id com.example.app
214
+
215
+ # Return the publishable key and hosted form URL for an authorized app setup
216
+ feedbackbasket mobile setup myapp --bundle-id com.example.app --include-publishable-key --agent
217
+
218
+ # Inspect and verify the SDK heartbeat
219
+ feedbackbasket mobile status myapp
220
+ feedbackbasket mobile verify myapp --bundle-id com.example.app --wait 120
221
+
222
+ # Add or remove bundle IDs without replacing the others
223
+ feedbackbasket mobile bundle-ids myapp --add com.example.app.beta
224
+ feedbackbasket mobile bundle-ids myapp --remove com.example.app.beta
225
+
226
+ # Actions that can interrupt installed apps require explicit confirmation
227
+ feedbackbasket mobile disable myapp --yes
228
+ feedbackbasket mobile rotate-key myapp --yes --include-publishable-key
229
+ ```
230
+
231
+ Agents should never repeat the full publishable key in their final response. They must never place `fb_cli_` or `fb_key_` credentials in a mobile app. Key rotation invalidates the previous key and therefore requires explicit user authorization.
232
+
233
+ The native Swift SDK securely stores reply-thread credentials in the app Keychain and shows team replies in the same feedback sheet. Host apps do not need to build an inbox or manage reply tokens. Hosted-form integrations remain email-only.
234
+
187
235
  ### Team
188
236
 
189
237
  ```bash
package/dist/src/cli.js CHANGED
@@ -11,6 +11,8 @@ import { createDoctorCommand } from './commands/doctor.js';
11
11
  import { createSetupCommand } from './commands/setup.js';
12
12
  import { createWidgetCommand } from './commands/widget.js';
13
13
  import { createTeamCommand } from './commands/team.js';
14
+ import { createWaitlistCommand } from './commands/waitlist.js';
15
+ import { createMobileCommand } from './commands/mobile.js';
14
16
  import { renderRootHelp } from './help.js';
15
17
  let writer;
16
18
  function resolveFormat(opts) {
@@ -62,6 +64,8 @@ export function run() {
62
64
  program.addCommand(createFeedbackCommand(getWriter));
63
65
  program.addCommand(createBugsCommand(getWriter));
64
66
  program.addCommand(createWidgetCommand(getWriter));
67
+ program.addCommand(createWaitlistCommand(getWriter));
68
+ program.addCommand(createMobileCommand(getWriter));
65
69
  program.addCommand(createTeamCommand(getWriter));
66
70
  program.addCommand(createDoctorCommand(getWriter));
67
71
  program.addCommand(createSetupCommand(getWriter));
@@ -1,6 +1,7 @@
1
- import type { ProjectsResponse, FeedbackResponse, BugReportsResponse, FeedbackParams, FeedbackCreateInput, FeedbackCreateResponse, FeedbackReplyResponse, BugReportParams, UserProfile, Project, Feedback, WidgetSettings } from './types.js';
1
+ import type { ProjectsResponse, FeedbackResponse, BugReportsResponse, FeedbackParams, FeedbackCreateInput, FeedbackCreateResponse, FeedbackReplyResponse, BugReportParams, UserProfile, Project, Feedback, WidgetSettings, WaitlistResponse, MobileIntegrationResponse } from './types.js';
2
2
  export declare class FeedbackBasketClient {
3
- private http;
3
+ private readonly apiBaseUrl;
4
+ private readonly token;
4
5
  constructor(token: string, baseUrl: string);
5
6
  me(): Promise<UserProfile>;
6
7
  listProjects(): Promise<ProjectsResponse>;
@@ -48,9 +49,23 @@ export declare class FeedbackBasketClient {
48
49
  getWidgetScript(projectId: string): Promise<{
49
50
  projectId: string;
50
51
  projectName: string;
52
+ captureMode: 'feedback' | 'waitlist';
51
53
  embedCode: string;
52
54
  scriptUrl: string;
53
55
  }>;
56
+ getMobileIntegration(projectId: string, includePublishableKey?: boolean): Promise<MobileIntegrationResponse>;
57
+ updateMobileIntegration(projectId: string, data: {
58
+ enabled?: boolean;
59
+ addBundleIds?: string[];
60
+ removeBundleIds?: string[];
61
+ }, includePublishableKey?: boolean): Promise<MobileIntegrationResponse>;
62
+ rotateMobileProjectKey(projectId: string, includePublishableKey?: boolean): Promise<MobileIntegrationResponse>;
63
+ getWaitlist(projectId: string, params?: {
64
+ search?: string;
65
+ limit?: number;
66
+ offset?: number;
67
+ }): Promise<WaitlistResponse>;
68
+ exportWaitlist(projectId: string): Promise<string>;
54
69
  updateFeedback(id: string, data: {
55
70
  status?: string;
56
71
  category?: string;
@@ -118,5 +133,4 @@ export declare class FeedbackBasketClient {
118
133
  email: string;
119
134
  }>;
120
135
  private request;
121
- private handleError;
122
136
  }
@@ -1,18 +1,11 @@
1
- import axios, { AxiosError } from 'axios';
2
1
  import { USER_AGENT } from './version.js';
3
- import { errAuth, errForbidden, errRateLimit, errNetwork, errAPI } from './output/errors.js';
2
+ import { CLIError, errAuth, errForbidden, errRateLimit, errNetwork, errAPI } from './output/errors.js';
4
3
  export class FeedbackBasketClient {
5
- http;
4
+ apiBaseUrl;
5
+ token;
6
6
  constructor(token, baseUrl) {
7
- this.http = axios.create({
8
- baseURL: `${baseUrl}/api/v1`,
9
- timeout: 30_000,
10
- headers: {
11
- 'Authorization': `Bearer ${token}`,
12
- 'Content-Type': 'application/json',
13
- 'User-Agent': USER_AGENT,
14
- },
15
- });
7
+ this.apiBaseUrl = `${baseUrl.replace(/\/$/, '')}/api/v1`;
8
+ this.token = token;
16
9
  }
17
10
  async me() {
18
11
  return this.request('GET', '/auth/me');
@@ -63,6 +56,27 @@ export class FeedbackBasketClient {
63
56
  async getWidgetScript(projectId) {
64
57
  return this.request('GET', `/projects/${encodeURIComponent(projectId)}/widget-script`);
65
58
  }
59
+ // Mobile feedback
60
+ async getMobileIntegration(projectId, includePublishableKey = false) {
61
+ const query = includePublishableKey ? '?includePublishableKey=true' : '';
62
+ return this.request('GET', `/projects/${encodeURIComponent(projectId)}/mobile${query}`);
63
+ }
64
+ async updateMobileIntegration(projectId, data, includePublishableKey = false) {
65
+ const query = includePublishableKey ? '?includePublishableKey=true' : '';
66
+ return this.request('PATCH', `/projects/${encodeURIComponent(projectId)}/mobile${query}`, data);
67
+ }
68
+ async rotateMobileProjectKey(projectId, includePublishableKey = false) {
69
+ const query = includePublishableKey ? '?includePublishableKey=true' : '';
70
+ return this.request('POST', `/projects/${encodeURIComponent(projectId)}/mobile/rotate-key${query}`);
71
+ }
72
+ async getWaitlist(projectId, params = {}) {
73
+ const query = buildQuery(params);
74
+ return this.request('GET', `/projects/${encodeURIComponent(projectId)}/waitlist${query}`);
75
+ }
76
+ async exportWaitlist(projectId) {
77
+ const data = await this.request('GET', `/projects/${encodeURIComponent(projectId)}/waitlist/export`);
78
+ return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
79
+ }
66
80
  // Write operations
67
81
  async updateFeedback(id, data) {
68
82
  return this.request('PATCH', `/feedback/${encodeURIComponent(id)}`, data);
@@ -95,8 +109,8 @@ export class FeedbackBasketClient {
95
109
  return this.request('DELETE', `/feedback/${encodeURIComponent(feedbackId)}/notes/${encodeURIComponent(noteId)}`);
96
110
  }
97
111
  async exportFeedback(projectId, format = 'csv') {
98
- const response = await this.http.get(`/projects/${encodeURIComponent(projectId)}/export?format=${format}`);
99
- return typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
112
+ const data = await this.request('GET', `/projects/${encodeURIComponent(projectId)}/export?format=${format}`);
113
+ return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
100
114
  }
101
115
  // Team
102
116
  async listTeam() {
@@ -109,34 +123,58 @@ export class FeedbackBasketClient {
109
123
  return this.request('DELETE', `/team/${encodeURIComponent(memberId)}`);
110
124
  }
111
125
  async request(method, path, data) {
126
+ const controller = new AbortController();
127
+ const timeout = setTimeout(() => controller.abort(), 30_000);
112
128
  try {
113
- const response = await this.http.request({ method, url: path, data });
114
- return response.data;
129
+ const response = await fetch(`${this.apiBaseUrl}${path}`, {
130
+ method,
131
+ signal: controller.signal,
132
+ headers: {
133
+ 'Authorization': `Bearer ${this.token}`,
134
+ 'Content-Type': 'application/json',
135
+ 'User-Agent': USER_AGENT,
136
+ },
137
+ body: data === undefined ? undefined : JSON.stringify(data),
138
+ });
139
+ const contentType = response.headers.get('content-type') ?? '';
140
+ const payload = contentType.includes('application/json')
141
+ ? await response.json().catch(() => null)
142
+ : await response.text();
143
+ if (!response.ok) {
144
+ const message = getErrorMessage(payload, response.statusText);
145
+ 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);
151
+ }
152
+ }
153
+ return payload;
115
154
  }
116
155
  catch (error) {
117
- throw this.handleError(error);
156
+ if (error instanceof CLIError)
157
+ throw error;
158
+ const cause = error instanceof Error ? error : new Error(String(error));
159
+ throw errNetwork(cause);
118
160
  }
119
- }
120
- handleError(error) {
121
- if (error instanceof AxiosError) {
122
- const status = error.response?.status;
123
- const message = error.response?.data?.error
124
- ?? error.response?.data?.message
125
- ?? error.message;
126
- if (!error.response) {
127
- return errNetwork(error);
128
- }
129
- switch (status) {
130
- case 401: return errAuth(message);
131
- case 403: return errForbidden(message);
132
- case 404: return errAPI(404, message);
133
- case 429: return errRateLimit();
134
- default: return errAPI(status ?? 500, message);
135
- }
161
+ finally {
162
+ clearTimeout(timeout);
136
163
  }
137
- return error instanceof Error ? error : new Error(String(error));
138
164
  }
139
165
  }
166
+ function getErrorMessage(payload, fallback) {
167
+ if (payload && typeof payload === 'object') {
168
+ const value = payload;
169
+ if (typeof value.error === 'string')
170
+ return value.error;
171
+ if (typeof value.message === 'string')
172
+ return value.message;
173
+ }
174
+ if (typeof payload === 'string' && payload.trim())
175
+ return payload;
176
+ return fallback || 'Request failed';
177
+ }
140
178
  function buildQuery(params) {
141
179
  const parts = [];
142
180
  for (const [key, value] of Object.entries(params)) {
@@ -1,4 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import type { OutputWriter } from '../output/writer.js';
3
+ import type { ReplyDelivery } from '../types.js';
3
4
  export declare function createFeedbackReplyCommand(getWriter: () => OutputWriter): Command;
4
5
  export declare function createFeedbackRepliesCommand(getWriter: () => OutputWriter): Command;
6
+ export declare function replyDestinations(delivery: ReplyDelivery): Array<'email' | 'widget'>;
@@ -5,14 +5,14 @@ import { loadConfig } from '../config/config.js';
5
5
  import { errAuth, errUsage } from '../output/errors.js';
6
6
  import { brand } from '../output/theme.js';
7
7
  import { ask } from '../prompt.js';
8
- const deliveryOptions = new Set(['email', 'widget', 'both']);
8
+ const deliveryOptions = new Set(['email', 'widget', 'in-app', 'both']);
9
9
  export function createFeedbackReplyCommand(getWriter) {
10
10
  return new Command('reply')
11
11
  .argument('<id>', 'Feedback ID to reply to')
12
12
  .argument('[content]', 'Reply content (or use --content)')
13
- .description('Reply to feedback by email, widget thread, or both')
13
+ .description('Reply to feedback by email, widget/in-app thread, or both')
14
14
  .option('--content <text>', 'Reply content (alternative to positional argument)')
15
- .option('--delivery <delivery>', 'Reply delivery (email, widget, both)', 'email')
15
+ .option('--delivery <delivery>', 'Reply delivery (email, widget, in-app, both)', 'email')
16
16
  .option('--reply-to <email>', 'Reply-to email for email delivery')
17
17
  .action(async (id, contentArg, opts) => {
18
18
  const writer = getWriter();
@@ -22,19 +22,22 @@ export function createFeedbackReplyCommand(getWriter) {
22
22
  throw errUsage('Reply content is required', 'Example: feedbackbasket feedback reply <id> "Thanks for reporting this!" --delivery widget');
23
23
  }
24
24
  if (!deliveryOptions.has(delivery)) {
25
- throw errUsage('Delivery must be email, widget, or both', 'Example: feedbackbasket feedback reply <id> "Thanks!" --delivery both --reply-to support@example.com');
25
+ throw errUsage('Delivery must be email, widget, in-app, or both', 'Example: feedbackbasket feedback reply <id> "Thanks!" --delivery both --reply-to support@example.com');
26
26
  }
27
27
  const client = requireClient();
28
28
  const feedback = await client.getFeedbackById(id);
29
- const destinations = delivery === 'both'
30
- ? ['email', 'widget']
31
- : [delivery];
29
+ if (delivery === 'in-app' && feedback.replyChannel !== 'in_app') {
30
+ throw errUsage('This feedback does not have an in-app reply thread.', 'Use --delivery widget for website-widget feedback or --delivery email when an email address is available.');
31
+ }
32
+ const destinations = replyDestinations(delivery);
32
33
  const sendsEmail = destinations.includes('email');
33
34
  const sendsWidget = destinations.includes('widget');
34
35
  let replyTo = opts.replyTo ?? feedback.project.replyToEmail ?? undefined;
35
36
  if (sendsEmail) {
36
37
  if (!feedback.email) {
37
- throw errUsage('This feedback has no email address; use --delivery widget if it has a widget thread.');
38
+ throw errUsage(feedback.replyChannel === 'in_app'
39
+ ? 'This feedback has no email address; use --delivery in-app.'
40
+ : 'This feedback has no email address; use --delivery widget if it has a widget thread.');
38
41
  }
39
42
  if (!replyTo) {
40
43
  const isInteractive = !writer.isMachineOutput() && process.stdin.isTTY;
@@ -56,7 +59,7 @@ export function createFeedbackReplyCommand(getWriter) {
56
59
  }
57
60
  }
58
61
  if (sendsWidget && !feedback.hasWidgetAccess) {
59
- throw errUsage('This feedback is not connected to a widget thread.', 'Use --delivery email for feedback with an email address, or ask the human how they want to respond.');
62
+ throw errUsage('This feedback has no in-app or widget reply thread.', 'Use --delivery email for feedback with an email address, or ask the human how they want to respond.');
60
63
  }
61
64
  const result = await client.sendReply(id, content, {
62
65
  replyToEmail: replyTo,
@@ -71,16 +74,16 @@ export function createFeedbackReplyCommand(getWriter) {
71
74
  }
72
75
  }
73
76
  if (result.message) {
74
- console.log(` ${brand.success('[OK]')} Widget reply posted`);
77
+ console.log(` ${brand.success('[OK]')} ${feedback.replyChannel === 'in_app' ? 'In-app' : 'Widget'} reply posted`);
75
78
  console.log(` ${brand.muted('By:')} ${result.message.sentByName ?? 'CLI'}`);
76
79
  }
77
80
  console.log();
78
81
  }
79
82
  writer.ok(result, {
80
83
  summary: delivery === 'both'
81
- ? 'Reply sent by email and widget'
82
- : delivery === 'widget'
83
- ? 'Widget reply posted'
84
+ ? `Reply sent by email and ${feedback.replyChannel === 'in_app' ? 'in-app' : 'widget'}`
85
+ : sendsWidget
86
+ ? `${feedback.replyChannel === 'in_app' ? 'In-app' : 'Widget'} reply posted`
84
87
  : `Reply sent to ${result.sentTo}`,
85
88
  breadcrumbs: [
86
89
  { action: 'View replies', cmd: `feedbackbasket feedback replies ${id}` },
@@ -139,6 +142,13 @@ export function createFeedbackRepliesCommand(getWriter) {
139
142
  });
140
143
  });
141
144
  }
145
+ export function replyDestinations(delivery) {
146
+ if (delivery === 'both')
147
+ return ['email', 'widget'];
148
+ if (delivery === 'in-app')
149
+ return ['widget'];
150
+ return [delivery];
151
+ }
142
152
  function requireClient() {
143
153
  const manager = new AuthManager();
144
154
  const token = manager.resolveToken();
@@ -186,7 +186,7 @@ function renderFeedbackDetail(item) {
186
186
  ['OS', item.os],
187
187
  ['Device', item.device],
188
188
  ['Language', item.language],
189
- ['Widget Thread', item.hasWidgetAccess ? 'Yes' : null],
189
+ ['Reply Channel', item.replyChannel === 'in_app' ? 'In-app' : item.hasWidgetAccess ? 'Widget' : null],
190
190
  ['Created', item.createdAt],
191
191
  ];
192
192
  for (const [label, value] of fields) {
@@ -0,0 +1,7 @@
1
+ import { Command } from 'commander';
2
+ import type { MobileIntegrationResponse } from '../types.js';
3
+ import type { OutputWriter } from '../output/writer.js';
4
+ export declare function createMobileCommand(getWriter: () => OutputWriter): Command;
5
+ export declare function projectRef(projectArg: string | undefined, projectId: string): string;
6
+ export declare function validateBundleIds(bundleIds: string[]): string[];
7
+ export declare function isMobileConnectionVerified(result: MobileIntegrationResponse, expectedBundleId?: string): boolean;