feedbackbasket-cli 0.10.0 → 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
@@ -202,6 +204,34 @@ The default widget experience is a basic modal. Only switch to popup mode or ena
202
204
  }
203
205
  ```
204
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
+
205
235
  ### Team
206
236
 
207
237
  ```bash
package/dist/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { createSetupCommand } from './commands/setup.js';
12
12
  import { createWidgetCommand } from './commands/widget.js';
13
13
  import { createTeamCommand } from './commands/team.js';
14
14
  import { createWaitlistCommand } from './commands/waitlist.js';
15
+ import { createMobileCommand } from './commands/mobile.js';
15
16
  import { renderRootHelp } from './help.js';
16
17
  let writer;
17
18
  function resolveFormat(opts) {
@@ -64,6 +65,7 @@ export function run() {
64
65
  program.addCommand(createBugsCommand(getWriter));
65
66
  program.addCommand(createWidgetCommand(getWriter));
66
67
  program.addCommand(createWaitlistCommand(getWriter));
68
+ program.addCommand(createMobileCommand(getWriter));
67
69
  program.addCommand(createTeamCommand(getWriter));
68
70
  program.addCommand(createDoctorCommand(getWriter));
69
71
  program.addCommand(createSetupCommand(getWriter));
@@ -1,4 +1,4 @@
1
- import type { ProjectsResponse, FeedbackResponse, BugReportsResponse, FeedbackParams, FeedbackCreateInput, FeedbackCreateResponse, FeedbackReplyResponse, BugReportParams, UserProfile, Project, Feedback, WidgetSettings, WaitlistResponse } 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
3
  private readonly apiBaseUrl;
4
4
  private readonly token;
@@ -53,6 +53,13 @@ export declare class FeedbackBasketClient {
53
53
  embedCode: string;
54
54
  scriptUrl: string;
55
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>;
56
63
  getWaitlist(projectId: string, params?: {
57
64
  search?: string;
58
65
  limit?: number;
@@ -56,6 +56,19 @@ export class FeedbackBasketClient {
56
56
  async getWidgetScript(projectId) {
57
57
  return this.request('GET', `/projects/${encodeURIComponent(projectId)}/widget-script`);
58
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
+ }
59
72
  async getWaitlist(projectId, params = {}) {
60
73
  const query = buildQuery(params);
61
74
  return this.request('GET', `/projects/${encodeURIComponent(projectId)}/waitlist${query}`);
@@ -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;
@@ -0,0 +1,292 @@
1
+ import { Command } from 'commander';
2
+ import { FeedbackBasketClient } from '../client.js';
3
+ import { AuthManager } from '../auth/manager.js';
4
+ import { loadConfig } from '../config/config.js';
5
+ import { errAuth, errUsage } from '../output/errors.js';
6
+ import { brand, divider } from '../output/theme.js';
7
+ import { confirm } from '../prompt.js';
8
+ import { resolveProject } from '../resolve.js';
9
+ const BUNDLE_ID_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
10
+ const SAFE_PROJECT_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(?: [A-Za-z0-9][A-Za-z0-9._-]*)*$/;
11
+ const VERIFY_POLL_INTERVAL_MS = 5_000;
12
+ const MAX_VERIFY_WAIT_SECONDS = 300;
13
+ export function createMobileCommand(getWriter) {
14
+ const mobile = new Command('mobile')
15
+ .description('Set up and verify mobile app feedback');
16
+ mobile
17
+ .command('status [project]')
18
+ .description('Show mobile feedback configuration and connection status')
19
+ .option('--include-publishable-key', 'Include the full publishable mobile project key')
20
+ .action(async (projectArg, opts) => {
21
+ const writer = getWriter();
22
+ const client = requireClient();
23
+ const projectId = await resolveProjectId(client, projectArg);
24
+ const ref = projectRef(projectArg, projectId);
25
+ const result = await client.getMobileIntegration(projectId, Boolean(opts.includePublishableKey));
26
+ if (!writer.isMachineOutput())
27
+ renderMobileStatus(result);
28
+ writer.ok(result, {
29
+ summary: `Mobile feedback for "${result.project.name}"`,
30
+ breadcrumbs: result.integration
31
+ ? [
32
+ { action: 'Verify SDK connection', cmd: `feedbackbasket mobile verify ${ref}` },
33
+ { action: 'Add a bundle ID', cmd: `feedbackbasket mobile bundle-ids ${ref} --add com.example.app` },
34
+ ]
35
+ : [
36
+ { action: 'Enable mobile feedback', cmd: `feedbackbasket mobile setup ${ref}` },
37
+ ],
38
+ });
39
+ });
40
+ mobile
41
+ .command('setup [project]')
42
+ .description('Enable mobile feedback and add allowed bundle IDs')
43
+ .option('--bundle-id <bundle-id>', 'Allowed iOS bundle ID (repeatable)', collect, [])
44
+ .option('--include-publishable-key', 'Include the full publishable key required to configure the app')
45
+ .action(async (projectArg, opts) => {
46
+ const writer = getWriter();
47
+ const client = requireClient();
48
+ const projectId = await resolveProjectId(client, projectArg);
49
+ const bundleIds = validateBundleIds(opts.bundleId);
50
+ const result = await client.updateMobileIntegration(projectId, { enabled: true, addBundleIds: bundleIds }, Boolean(opts.includePublishableKey));
51
+ if (!writer.isMachineOutput()) {
52
+ console.log(` ${brand.success('✓')} Mobile feedback enabled for ${brand.bold(result.project.name)}`);
53
+ if (!opts.includePublishableKey) {
54
+ console.log(` ${brand.muted('The publishable key is masked. Re-run with --include-publishable-key when configuring the app.')}`);
55
+ }
56
+ console.log();
57
+ }
58
+ const ref = projectRef(projectArg, projectId);
59
+ writer.ok(withSetupGuidance(result), {
60
+ summary: `Mobile feedback ready for "${result.project.name}"`,
61
+ breadcrumbs: [
62
+ { action: 'Check connection', cmd: `feedbackbasket mobile verify ${ref}` },
63
+ { action: 'View mobile status', cmd: `feedbackbasket mobile status ${ref}` },
64
+ ],
65
+ });
66
+ });
67
+ mobile
68
+ .command('bundle-ids [project]')
69
+ .description('Add or remove allowed iOS bundle IDs')
70
+ .option('--add <bundle-id>', 'Bundle ID to add (repeatable)', collect, [])
71
+ .option('--remove <bundle-id>', 'Bundle ID to remove (repeatable)', collect, [])
72
+ .action(async (projectArg, opts) => {
73
+ const writer = getWriter();
74
+ const client = requireClient();
75
+ const additions = validateBundleIds(opts.add);
76
+ const removals = validateBundleIds(opts.remove);
77
+ if (additions.length === 0 && removals.length === 0) {
78
+ throw errUsage('Pass at least one --add or --remove bundle ID', 'Example: feedbackbasket mobile bundle-ids myapp --add com.example.app');
79
+ }
80
+ const overlap = additions.filter((bundleId) => removals.includes(bundleId));
81
+ if (overlap.length > 0) {
82
+ throw errUsage(`The same bundle ID cannot be added and removed: ${overlap.join(', ')}`);
83
+ }
84
+ const projectId = await resolveProjectId(client, projectArg);
85
+ const result = await client.updateMobileIntegration(projectId, {
86
+ addBundleIds: additions,
87
+ removeBundleIds: removals,
88
+ });
89
+ if (!writer.isMachineOutput()) {
90
+ console.log(` ${brand.success('✓')} Allowed bundle IDs updated for ${brand.bold(result.project.name)}`);
91
+ console.log();
92
+ }
93
+ writer.ok(result, {
94
+ summary: `Updated mobile bundle IDs for "${result.project.name}"`,
95
+ breadcrumbs: [
96
+ { action: 'View mobile status', cmd: `feedbackbasket mobile status ${projectRef(projectArg, projectId)}` },
97
+ ],
98
+ });
99
+ });
100
+ mobile
101
+ .command('verify [project]')
102
+ .description('Verify that the SDK has connected from the expected app')
103
+ .option('--bundle-id <bundle-id>', 'Expected bundle ID')
104
+ .option('--wait <seconds>', 'Wait for the first matching heartbeat (maximum 300 seconds)', '0')
105
+ .action(async (projectArg, opts) => {
106
+ const writer = getWriter();
107
+ const client = requireClient();
108
+ const projectId = await resolveProjectId(client, projectArg);
109
+ const expectedBundleId = opts.bundleId
110
+ ? validateBundleIds([opts.bundleId])[0]
111
+ : undefined;
112
+ const waitSeconds = parseWaitSeconds(opts.wait);
113
+ const deadline = Date.now() + waitSeconds * 1_000;
114
+ let result = await client.getMobileIntegration(projectId);
115
+ while (!isMobileConnectionVerified(result, expectedBundleId) && Date.now() < deadline) {
116
+ await sleep(Math.min(VERIFY_POLL_INTERVAL_MS, deadline - Date.now()));
117
+ result = await client.getMobileIntegration(projectId);
118
+ }
119
+ const verified = isMobileConnectionVerified(result, expectedBundleId);
120
+ const verification = {
121
+ verified,
122
+ expectedBundleId: expectedBundleId ?? null,
123
+ project: result.project,
124
+ integration: result.integration,
125
+ };
126
+ if (!writer.isMachineOutput())
127
+ renderVerification(verification);
128
+ writer.ok(verification, {
129
+ summary: verified
130
+ ? `Verified mobile connection for "${result.project.name}"`
131
+ : `Mobile connection not yet verified for "${result.project.name}"`,
132
+ notice: verified
133
+ ? undefined
134
+ : 'Build and launch the app, then run this command again with --wait 120.',
135
+ });
136
+ if (!verified)
137
+ process.exitCode = 2;
138
+ });
139
+ mobile
140
+ .command('disable [project]')
141
+ .description('Disable mobile feedback without changing the website widget')
142
+ .option('--yes', 'Confirm disabling mobile feedback')
143
+ .action(async (projectArg, opts) => {
144
+ const writer = getWriter();
145
+ const client = requireClient();
146
+ const projectId = await resolveProjectId(client, projectArg);
147
+ await requireDestructiveConfirmation(writer, Boolean(opts.yes), 'Disable mobile feedback? Installed apps will stop submitting feedback.', '--yes is required to disable mobile feedback in agent mode');
148
+ const result = await client.updateMobileIntegration(projectId, { enabled: false });
149
+ if (!writer.isMachineOutput()) {
150
+ console.log(` ${brand.success('✓')} Mobile feedback disabled for ${brand.bold(result.project.name)}`);
151
+ console.log();
152
+ }
153
+ writer.ok(result, {
154
+ summary: `Disabled mobile feedback for "${result.project.name}"`,
155
+ breadcrumbs: [
156
+ { action: 'Re-enable mobile feedback', cmd: `feedbackbasket mobile setup ${projectRef(projectArg, projectId)}` },
157
+ ],
158
+ });
159
+ });
160
+ mobile
161
+ .command('rotate-key [project]')
162
+ .description('Rotate the publishable mobile key (existing app builds will stop working)')
163
+ .option('--yes', 'Confirm key rotation')
164
+ .option('--include-publishable-key', 'Include the newly generated publishable key')
165
+ .action(async (projectArg, opts) => {
166
+ const writer = getWriter();
167
+ const client = requireClient();
168
+ const projectId = await resolveProjectId(client, projectArg);
169
+ await requireDestructiveConfirmation(writer, Boolean(opts.yes), 'Rotate the mobile project key? Existing app builds will stop submitting feedback.', '--yes is required to rotate a mobile project key in agent mode');
170
+ const result = await client.rotateMobileProjectKey(projectId, Boolean(opts.includePublishableKey));
171
+ if (!writer.isMachineOutput()) {
172
+ console.log(` ${brand.warning('!')} Mobile project key rotated for ${brand.bold(result.project.name)}`);
173
+ console.log(' Update and release every installed app that used the previous key.');
174
+ console.log();
175
+ }
176
+ writer.ok(result, {
177
+ summary: `Rotated mobile project key for "${result.project.name}"`,
178
+ notice: 'Existing app builds using the previous key can no longer submit feedback.',
179
+ });
180
+ });
181
+ return mobile;
182
+ }
183
+ function requireClient() {
184
+ const manager = new AuthManager();
185
+ const token = manager.resolveToken();
186
+ if (!token)
187
+ throw errAuth();
188
+ return new FeedbackBasketClient(token, loadConfig().baseUrl);
189
+ }
190
+ async function resolveProjectId(client, projectArg) {
191
+ if (projectArg)
192
+ return (await resolveProject(client, projectArg)).id;
193
+ const config = loadConfig();
194
+ if (config.defaultProject)
195
+ return config.defaultProject;
196
+ throw errUsage('Project is required. Pass a project name/ID or set a default.', 'feedbackbasket mobile status <project>');
197
+ }
198
+ function collect(value, previous) {
199
+ return [...previous, value];
200
+ }
201
+ export function projectRef(projectArg, projectId) {
202
+ const ref = projectArg?.trim();
203
+ if (!ref || !SAFE_PROJECT_REF_PATTERN.test(ref))
204
+ return projectId;
205
+ return ref.includes(' ') ? `"${ref}"` : ref;
206
+ }
207
+ export function validateBundleIds(bundleIds) {
208
+ const normalized = Array.from(new Set(bundleIds.map((value) => value.trim()).filter(Boolean)));
209
+ const invalid = normalized.find((bundleId) => !BUNDLE_ID_PATTERN.test(bundleId));
210
+ if (invalid) {
211
+ throw errUsage(`Invalid bundle ID "${invalid}"`, 'Bundle IDs must look like com.example.app');
212
+ }
213
+ if (normalized.length > 20)
214
+ throw errUsage('A project can have at most 20 bundle IDs');
215
+ return normalized;
216
+ }
217
+ export function isMobileConnectionVerified(result, expectedBundleId) {
218
+ const integration = result.integration;
219
+ if (!integration?.enabled || !integration.connection.connected)
220
+ return false;
221
+ return !expectedBundleId || integration.connection.bundleId === expectedBundleId;
222
+ }
223
+ function parseWaitSeconds(value) {
224
+ const seconds = Number(value);
225
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > MAX_VERIFY_WAIT_SECONDS) {
226
+ throw errUsage(`--wait must be a whole number between 0 and ${MAX_VERIFY_WAIT_SECONDS}`);
227
+ }
228
+ return seconds;
229
+ }
230
+ function sleep(milliseconds) {
231
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
232
+ }
233
+ async function requireDestructiveConfirmation(writer, confirmedByFlag, question, agentHint) {
234
+ if (confirmedByFlag)
235
+ return;
236
+ if (writer.isMachineOutput() || !process.stdin.isTTY) {
237
+ throw errUsage(agentHint);
238
+ }
239
+ if (!(await confirm(` ${question}`, false))) {
240
+ throw errUsage('Action cancelled');
241
+ }
242
+ }
243
+ function withSetupGuidance(result) {
244
+ return {
245
+ ...result,
246
+ setup: {
247
+ publishableKeyIncluded: result.integration?.publishableKeyIncluded ?? false,
248
+ supportedIntegrations: ['swiftui', 'uikit', 'react-native', 'flutter', 'hosted-form'],
249
+ nextSteps: result.integration?.publishableKeyIncluded
250
+ ? [
251
+ 'Detect the mobile framework and minimum supported platform version.',
252
+ 'Configure the app with the returned publishable mobile project key.',
253
+ 'Add an accessible Send feedback action to an existing Settings, Help, or Support screen.',
254
+ 'Build and launch the app, then verify its SDK heartbeat.',
255
+ ]
256
+ : [
257
+ 'Re-run setup with --include-publishable-key when you are ready to configure the app.',
258
+ ],
259
+ },
260
+ };
261
+ }
262
+ function renderMobileStatus(result) {
263
+ console.log(brand.bold(`Mobile feedback — ${result.project.name}`));
264
+ console.log(divider(54));
265
+ if (!result.integration) {
266
+ console.log(brand.muted(' Mobile feedback is not enabled.'));
267
+ console.log();
268
+ return;
269
+ }
270
+ const integration = result.integration;
271
+ console.log(` ${brand.label('Status'.padEnd(18))} ${integration.enabled ? 'Enabled' : 'Disabled'}`);
272
+ console.log(` ${brand.label('Publishable key'.padEnd(18))} ${integration.publishableKey}`);
273
+ console.log(` ${brand.label('Bundle IDs'.padEnd(18))} ${integration.bundleIds.join(', ') || 'Any bundle ID'}`);
274
+ console.log(` ${brand.label('Connection'.padEnd(18))} ${integration.connection.connected ? 'Connected' : 'Waiting for first heartbeat'}`);
275
+ if (integration.connection.lastSeenAt) {
276
+ console.log(` ${brand.label('Last seen'.padEnd(18))} ${integration.connection.lastSeenAt}`);
277
+ }
278
+ if (integration.connection.bundleId) {
279
+ console.log(` ${brand.label('Last bundle'.padEnd(18))} ${integration.connection.bundleId}`);
280
+ }
281
+ console.log();
282
+ }
283
+ function renderVerification(result) {
284
+ const icon = result.verified ? brand.success('✓') : brand.warning('!');
285
+ console.log(` ${icon} ${result.verified ? 'Mobile SDK connection verified' : 'Mobile SDK connection not yet verified'}`);
286
+ if (result.expectedBundleId)
287
+ console.log(` ${brand.muted(`Expected bundle: ${result.expectedBundleId}`)}`);
288
+ if (result.integration?.connection.lastSeenAt) {
289
+ console.log(` ${brand.muted(`Last heartbeat: ${result.integration.connection.lastSeenAt}`)}`);
290
+ }
291
+ console.log();
292
+ }
package/dist/src/help.js CHANGED
@@ -17,7 +17,7 @@ export function renderRootHelp() {
17
17
  lines.push(` ${logo()} CLI ${brand.muted(`v${VERSION}`)}`);
18
18
  lines.push('');
19
19
  lines.push(` ${brand.muted('The command-line interface for FeedbackBasket.')}`);
20
- lines.push(` ${brand.muted('Manage projects, feedback, waitlists, widgets, and team from your terminal.')}`);
20
+ lines.push(` ${brand.muted('Manage projects, feedback, mobile apps, waitlists, widgets, and team from your terminal.')}`);
21
21
  lines.push('');
22
22
  // Core Commands
23
23
  lines.push(section(' CORE COMMANDS'));
@@ -26,6 +26,7 @@ export function renderRootHelp() {
26
26
  lines.push(cmd('bugs', 'View bug reports with severity'));
27
27
  lines.push(cmd('widget', 'Manage feedback widget & get embed code'));
28
28
  lines.push(cmd('waitlist', 'View and export waitlist signups'));
29
+ lines.push(cmd('mobile', 'Set up and verify mobile app feedback'));
29
30
  lines.push(cmd('team', 'Manage organization members'));
30
31
  lines.push('');
31
32
  // Shortcuts
@@ -66,6 +67,8 @@ export function renderRootHelp() {
66
67
  lines.push(`${INDENT}${brand.muted('$')} feedbackbasket widget settings myapp --display modal`);
67
68
  lines.push(`${INDENT}${brand.muted('$')} feedbackbasket widget settings myapp --capture-mode waitlist`);
68
69
  lines.push(`${INDENT}${brand.muted('$')} feedbackbasket waitlist list myapp --search "@example.com"`);
70
+ lines.push(`${INDENT}${brand.muted('$')} feedbackbasket mobile setup myapp --bundle-id com.example.app`);
71
+ lines.push(`${INDENT}${brand.muted('$')} feedbackbasket mobile verify myapp --bundle-id com.example.app --wait 120`);
69
72
  lines.push(`${INDENT}${brand.muted('$')} feedbackbasket projects create "My App" --url https://myapp.com`);
70
73
  lines.push('');
71
74
  // Learn More
@@ -2,7 +2,7 @@ export type FeedbackStatus = 'OPEN' | 'UNDER_REVIEW' | 'PLANNED' | 'IN_PROGRESS'
2
2
  export type FeedbackCategory = 'BUG' | 'FEATURE_REQUEST' | 'IMPROVEMENT' | 'QUESTION';
3
3
  export type Sentiment = 'POSITIVE' | 'NEGATIVE' | 'NEUTRAL';
4
4
  export type Severity = 'high' | 'medium' | 'low';
5
- export type ReplyDelivery = 'email' | 'widget' | 'both';
5
+ export type ReplyDelivery = 'email' | 'widget' | 'in-app' | 'both';
6
6
  export type FeedbackFlowQuestionType = 'text' | 'textarea' | 'single_choice';
7
7
  export interface FeedbackFlowQuestion {
8
8
  id: string;
@@ -60,6 +60,32 @@ export interface Project {
60
60
  byStatus: Record<string, number>;
61
61
  byCategory: Record<string, number>;
62
62
  }
63
+ export interface MobileIntegrationResponse {
64
+ project: {
65
+ id: string;
66
+ name: string;
67
+ url: string;
68
+ };
69
+ integration: {
70
+ enabled: boolean;
71
+ publishableKey: string;
72
+ publishableKeyIncluded: boolean;
73
+ hostedFormUrl: string | null;
74
+ bundleIds: string[];
75
+ connection: {
76
+ connected: boolean;
77
+ lastSeenAt: string | null;
78
+ platform: string | null;
79
+ sdkVersion: string | null;
80
+ bundleId: string | null;
81
+ };
82
+ } | null;
83
+ sdk: {
84
+ swiftPackageUrl: string;
85
+ minimumIOSVersion: string;
86
+ productionBaseUrl: string;
87
+ };
88
+ }
63
89
  export interface Feedback {
64
90
  id: string;
65
91
  content: string;
@@ -89,6 +115,7 @@ export interface Feedback {
89
115
  device?: string | null;
90
116
  language?: string | null;
91
117
  hasWidgetAccess?: boolean;
118
+ replyChannel?: 'widget' | 'in_app' | null;
92
119
  attachments?: FeedbackAttachment[];
93
120
  project: {
94
121
  id: string;
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.10.0";
2
- export declare const USER_AGENT = "FeedbackBasket-CLI/0.10.0";
1
+ export declare const VERSION = "0.11.0";
2
+ export declare const USER_AGENT = "FeedbackBasket-CLI/0.11.0";
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.10.0';
1
+ export const VERSION = '0.11.0';
2
2
  export const USER_AGENT = `FeedbackBasket-CLI/${VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedbackbasket-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Command-line interface for FeedbackBasket — manage feedback and waitlists from your terminal",
5
5
  "type": "module",
6
6
  "main": "dist/src/cli.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc",
12
+ "test": "tsx --test tests/*.test.ts",
12
13
  "dev": "tsx bin/feedbackbasket.ts",
13
14
  "start": "node dist/bin/feedbackbasket.js",
14
15
  "prepublishOnly": "npm run build"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feedbackbasket
3
- description: Manage FeedbackBasket projects, feedback, bugs, feedback widgets, waitlist capture, and teams from the command line. Use when an agent needs to configure FeedbackBasket, collect feedback or waitlist signups, query feedback, or manage a FeedbackBasket project.
3
+ description: Manage FeedbackBasket projects, feedback, bugs, website widgets, mobile app feedback, waitlist capture, and teams from the command line. Use whenever an agent needs to configure FeedbackBasket in a web or mobile app, install its Swift SDK or hosted mobile form, collect feedback or waitlist signups, query feedback, or manage a FeedbackBasket project.
4
4
  ---
5
5
 
6
6
  # FeedbackBasket CLI
@@ -55,6 +55,59 @@ All project commands accept **name or ID**. Names are matched case-insensitively
55
55
 
56
56
  **Capture-mode decision:** if the user asks for feedback, a feedback bubble, bug reports, or feature requests, use `--capture-mode feedback`. If they ask for a waitlist, launch list, early access, or email capture, use `--capture-mode waitlist`. If they ask to set up FeedbackBasket without choosing, explain both options and ask which they want. Do not switch an existing project without confirmation because only one capture mode is active at a time.
57
57
 
58
+ **Mobile project selection rule:** resolve the FeedbackBasket project for the current app before running mobile commands. Prefer a clearly matching existing project name or product URL. If multiple projects are plausible, ask the user. If none exists, confirm a real product, support, marketing, or App Store URL before creating one; do not invent a URL or use a local development address.
59
+
60
+ ### Mobile App Feedback
61
+
62
+ ```bash
63
+ feedbackbasket mobile status <project> --agent
64
+ feedbackbasket mobile setup <project> --bundle-id com.example.app --agent
65
+ feedbackbasket mobile setup <project> --bundle-id com.example.app --include-publishable-key --agent
66
+ feedbackbasket mobile bundle-ids <project> --add com.example.app.beta --agent
67
+ feedbackbasket mobile bundle-ids <project> --remove com.example.app.beta --agent
68
+ feedbackbasket mobile verify <project> --bundle-id com.example.app --wait 120 --agent
69
+ feedbackbasket mobile disable <project> --yes --agent
70
+ feedbackbasket mobile rotate-key <project> --yes --include-publishable-key --agent
71
+ ```
72
+
73
+ The `fb_mobile_` project key is a publishable, write-only identifier designed to ship in the app. It cannot read feedback or administer the project. It is still masked by default to reduce accidental disclosure in logs and transcripts. Use `--include-publishable-key` only while performing a mobile setup the user authorized, and never repeat the full value in the final response.
74
+
75
+ Never put an `fb_cli_` CLI token or `fb_key_` MCP/API key in application source, build settings, prompts, logs, or generated configuration. Those are private credentials and are not interchangeable with the publishable mobile key.
76
+
77
+ For SwiftUI apps targeting iOS 16 or later, use the Swift package returned by `mobile setup` and its native feedback sheet. For UIKit, use the package API or host the SwiftUI sheet. For React Native, Flutter, or unsupported stacks, use the returned hosted form URL in the app's existing in-app browser when available.
78
+
79
+ Configure the Swift package once at app startup with the returned publishable key:
80
+
81
+ ```swift
82
+ import FeedbackBasket
83
+
84
+ FeedbackBasket.configure(
85
+ projectKey: "fb_mobile_returned_by_mobile_setup"
86
+ )
87
+ ```
88
+
89
+ Present its standard SwiftUI sheet from the selected Settings, Help, or Support view:
90
+
91
+ ```swift
92
+ @State private var showingFeedback = false
93
+
94
+ Button("Send feedback") {
95
+ showingFeedback = true
96
+ }
97
+ .feedbackBasketSheet(
98
+ isPresented: $showingFeedback,
99
+ context: ["screen": "Settings"]
100
+ )
101
+ ```
102
+
103
+ The native SDK stores each submission's reply-thread credential in the app Keychain and shows team replies in the same feedback sheet when it is opened again. Do not build a separate inbox, polling client, or token store in the host app. Hosted-form integrations remain email-only.
104
+
105
+ Add an accessible Send feedback action to an appropriate existing Settings, Help, or Support screen. Attach only useful non-sensitive context. Do not send passwords, authentication tokens, payment information, private form contents, crash reports, analytics, session recordings, or automatic logs.
106
+
107
+ Treat a supplied project key as production unless the user explicitly confirms a staging key and base URL. Build and launch the app so the SDK can send its heartbeat, then use `mobile verify`; do not submit test feedback to production. A prior matching heartbeat is a valid connection result because the SDK throttles successful heartbeat attempts.
108
+
109
+ `mobile setup` is idempotent and adds bundle IDs without replacing existing entries. Do not rotate a key or disable mobile feedback unless the user explicitly requested that disruptive action. Rotation stops every released app using the previous key.
110
+
58
111
  ### Feedback
59
112
  ```bash
60
113
  # Read
@@ -71,9 +124,10 @@ feedbackbasket feedback note <id> "Investigating — appears related to auth flo
71
124
  feedbackbasket feedback delete <id> --yes
72
125
  feedbackbasket feedback bulk-update --status CLOSED --ids id1,id2,id3
73
126
 
74
- # Reply to submitter by email, widget thread, or both
127
+ # Reply to submitter by email, widget/in-app thread, or both
75
128
  feedbackbasket feedback reply <id> "Thanks for reporting — we pushed a fix!" --delivery email --reply-to support@example.com
76
129
  feedbackbasket feedback reply <id> "<content>" --delivery widget
130
+ feedbackbasket feedback reply <id> "<content>" --delivery in-app
77
131
  feedbackbasket feedback reply <id> "<content>" --delivery both --reply-to support@example.com
78
132
  feedbackbasket feedback replies <id> # list past replies
79
133
 
@@ -257,17 +311,18 @@ feedbackbasket feedback show <id> --agent
257
311
  ### Close the loop — reply to the submitter
258
312
  ```bash
259
313
  # Agent reads context, asks which delivery method to use, then sends it
260
- feedbackbasket feedback show <id> --agent # read email, hasWidgetAccess, project.replyToEmail
314
+ feedbackbasket feedback show <id> --agent # read email, replyChannel, project.replyToEmail
261
315
  feedbackbasket feedback reply <id> "<drafted response>" --delivery widget --agent
316
+ feedbackbasket feedback reply <id> "<drafted response>" --delivery in-app --agent
262
317
  feedbackbasket feedback reply <id> "<drafted response>" --delivery email --reply-to support@example.com --agent
263
318
  feedbackbasket feedback reply <id> "<drafted response>" --delivery both --reply-to support@example.com --agent
264
319
  feedbackbasket feedback update <id> --status COMPLETE --agent
265
320
  feedbackbasket feedback note <id> "Replied via CLI" --agent
266
321
  ```
267
322
  **Important reply safety rules:**
268
- - Before replying, the agent MUST inspect `feedback show --agent`, then ask the human which delivery method to use: `email`, `widget`, or `both`, unless the human already specified it in the current conversation.
269
- - If `feedback show` returns `email: null`, do not use `--delivery email` or `--delivery both`. If `hasWidgetAccess: true`, use `--delivery widget`; otherwise ask the human how they want to respond.
270
- - If `hasWidgetAccess: false`, do not use `--delivery widget` or `--delivery both`.
323
+ - Before replying, the agent MUST inspect `feedback show --agent`, including `replyChannel`, then ask the human which available delivery method to use unless the human already specified it in the current conversation.
324
+ - If `replyChannel: "in_app"`, use `--delivery in-app`. If `replyChannel: "widget"`, use `--delivery widget`. Use `--delivery both` only when an email address and a reply channel are both available.
325
+ - If `feedback show` returns `email: null`, do not use `--delivery email` or `--delivery both`. If `replyChannel: null`, do not use thread delivery.
271
326
  - If the delivery includes email and `project.replyToEmail: null`, the agent MUST ask the human which reply-to email to use before sending. Do not use the account owner's email, token owner's email, or any remembered address without explicit confirmation in the current conversation.
272
327
  - After the human confirms a reply-to address, pass it explicitly with `--reply-to <email>`, or set a project default first with `feedbackbasket projects update <project> --reply-to <email>`.
273
328