feedbackbasket-cli 0.10.0 → 0.12.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 +336 -300
- package/dist/src/cli.js +2 -0
- package/dist/src/client.d.ts +9 -1
- package/dist/src/client.js +13 -0
- package/dist/src/commands/feedback-reply.d.ts +2 -0
- package/dist/src/commands/feedback-reply.js +51 -32
- package/dist/src/commands/feedback.js +2 -1
- package/dist/src/commands/mobile.d.ts +7 -0
- package/dist/src/commands/mobile.js +319 -0
- package/dist/src/commands/widget.js +6 -1
- package/dist/src/help.js +4 -1
- package/dist/src/types.d.ts +32 -1
- package/dist/src/version.d.ts +2 -2
- package/dist/src/version.js +1 -1
- package/package.json +49 -48
- package/skills/feedbackbasket/SKILL.md +390 -330
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));
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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,14 @@ 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
|
+
allowVisitorReplies?: boolean;
|
|
60
|
+
addBundleIds?: string[];
|
|
61
|
+
removeBundleIds?: string[];
|
|
62
|
+
}, includePublishableKey?: boolean): Promise<MobileIntegrationResponse>;
|
|
63
|
+
rotateMobileProjectKey(projectId: string, includePublishableKey?: boolean): Promise<MobileIntegrationResponse>;
|
|
56
64
|
getWaitlist(projectId: string, params?: {
|
|
57
65
|
search?: string;
|
|
58
66
|
limit?: number;
|
package/dist/src/client.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
|
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
|
-
?
|
|
82
|
-
:
|
|
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}` },
|
|
@@ -93,44 +96,53 @@ export function createFeedbackReplyCommand(getWriter) {
|
|
|
93
96
|
export function createFeedbackRepliesCommand(getWriter) {
|
|
94
97
|
return new Command('replies')
|
|
95
98
|
.argument('<id>', 'Feedback ID')
|
|
96
|
-
.description('List
|
|
99
|
+
.description('List the complete feedback conversation')
|
|
97
100
|
.action(async (id) => {
|
|
98
101
|
const writer = getWriter();
|
|
99
102
|
const client = requireClient();
|
|
100
103
|
const result = await client.listReplies(id);
|
|
101
104
|
const messages = result.messages ?? [];
|
|
102
|
-
const
|
|
105
|
+
const linkedReplyIds = new Set(messages.map((item) => item.replyId).filter((replyId) => replyId !== null));
|
|
106
|
+
const visibleEmailReplies = result.replies.filter((item) => !linkedReplyIds.has(item.id));
|
|
107
|
+
const conversation = [
|
|
108
|
+
...visibleEmailReplies.map((item) => ({ kind: 'email', item })),
|
|
109
|
+
...messages.map((item) => ({ kind: 'thread', item })),
|
|
110
|
+
].sort((left, right) => new Date(left.item.createdAt).getTime() - new Date(right.item.createdAt).getTime());
|
|
103
111
|
if (!writer.isMachineOutput()) {
|
|
104
112
|
if (result.total === 0) {
|
|
105
113
|
console.log(brand.muted(' No replies sent yet.'));
|
|
106
114
|
console.log();
|
|
107
115
|
}
|
|
108
116
|
else {
|
|
109
|
-
console.log(brand.bold(`${result.total}
|
|
117
|
+
console.log(brand.bold(`${result.total} conversation message${result.total === 1 ? '' : 's'} for feedback ${id}`));
|
|
110
118
|
console.log();
|
|
111
|
-
for (const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
console.log(
|
|
119
|
+
for (const entry of conversation) {
|
|
120
|
+
if (entry.kind === 'email') {
|
|
121
|
+
const emailReply = entry.item;
|
|
122
|
+
console.log(` ${brand.success('->')} ${brand.bold(emailReply.sentBy)} ${brand.muted(emailReply.createdAt)}`);
|
|
123
|
+
console.log(` ${brand.muted('Delivery:')} email`);
|
|
124
|
+
console.log(` ${brand.muted('Reply-to:')} ${emailReply.replyToEmail}`);
|
|
125
|
+
console.log();
|
|
126
|
+
for (const line of emailReply.content.split('\n')) {
|
|
127
|
+
console.log(` ${line}`);
|
|
128
|
+
}
|
|
118
129
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
else {
|
|
131
|
+
const message = entry.item;
|
|
132
|
+
const visitor = message.senderType === 'VISITOR';
|
|
133
|
+
console.log(` ${visitor ? brand.primary('<-') : brand.success('->')} ${brand.bold(visitor ? 'User' : (message.sentByName ?? 'CLI'))} ${brand.muted(message.createdAt)}`);
|
|
134
|
+
console.log(` ${brand.muted('Delivery:')} ${visitor ? 'visitor follow-up' : message.delivery === 'BOTH' ? 'email + widget/in-app' : 'widget/in-app'}`);
|
|
135
|
+
console.log();
|
|
136
|
+
for (const line of message.content.split('\n')) {
|
|
137
|
+
console.log(` ${line}`);
|
|
138
|
+
}
|
|
127
139
|
}
|
|
128
140
|
console.log();
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
143
|
}
|
|
132
144
|
writer.ok({ replies: result.replies, messages }, {
|
|
133
|
-
summary: `${result.total}
|
|
145
|
+
summary: `${result.total} conversation message${result.total === 1 ? '' : 's'}`,
|
|
134
146
|
breadcrumbs: [
|
|
135
147
|
{ action: 'Send an email reply', cmd: `feedbackbasket feedback reply ${id} "<content>" --delivery email` },
|
|
136
148
|
{ action: 'Post a widget reply', cmd: `feedbackbasket feedback reply ${id} "<content>" --delivery widget` },
|
|
@@ -139,6 +151,13 @@ export function createFeedbackRepliesCommand(getWriter) {
|
|
|
139
151
|
});
|
|
140
152
|
});
|
|
141
153
|
}
|
|
154
|
+
export function replyDestinations(delivery) {
|
|
155
|
+
if (delivery === 'both')
|
|
156
|
+
return ['email', 'widget'];
|
|
157
|
+
if (delivery === 'in-app')
|
|
158
|
+
return ['widget'];
|
|
159
|
+
return [delivery];
|
|
160
|
+
}
|
|
142
161
|
function requireClient() {
|
|
143
162
|
const manager = new AuthManager();
|
|
144
163
|
const token = manager.resolveToken();
|
|
@@ -186,7 +186,8 @@ function renderFeedbackDetail(item) {
|
|
|
186
186
|
['OS', item.os],
|
|
187
187
|
['Device', item.device],
|
|
188
188
|
['Language', item.language],
|
|
189
|
-
['
|
|
189
|
+
['Reply Channel', item.replyChannel === 'in_app' ? 'In-app' : item.hasWidgetAccess ? 'Widget' : null],
|
|
190
|
+
['Conversation', item.awaitingOwnerReply ? 'User replied — team response needed' : null],
|
|
190
191
|
['Created', item.createdAt],
|
|
191
192
|
];
|
|
192
193
|
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,319 @@
|
|
|
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
|
+
{ action: 'Configure conversations', cmd: `feedbackbasket mobile conversations ${ref} --enable` },
|
|
35
|
+
]
|
|
36
|
+
: [
|
|
37
|
+
{ action: 'Enable mobile feedback', cmd: `feedbackbasket mobile setup ${ref}` },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
mobile
|
|
42
|
+
.command('setup [project]')
|
|
43
|
+
.description('Enable mobile feedback and add allowed bundle IDs')
|
|
44
|
+
.option('--bundle-id <bundle-id>', 'Allowed iOS bundle ID (repeatable)', collect, [])
|
|
45
|
+
.option('--include-publishable-key', 'Include the full publishable key required to configure the app')
|
|
46
|
+
.action(async (projectArg, opts) => {
|
|
47
|
+
const writer = getWriter();
|
|
48
|
+
const client = requireClient();
|
|
49
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
50
|
+
const bundleIds = validateBundleIds(opts.bundleId);
|
|
51
|
+
const result = await client.updateMobileIntegration(projectId, { enabled: true, addBundleIds: bundleIds }, Boolean(opts.includePublishableKey));
|
|
52
|
+
if (!writer.isMachineOutput()) {
|
|
53
|
+
console.log(` ${brand.success('✓')} Mobile feedback enabled for ${brand.bold(result.project.name)}`);
|
|
54
|
+
if (!opts.includePublishableKey) {
|
|
55
|
+
console.log(` ${brand.muted('The publishable key is masked. Re-run with --include-publishable-key when configuring the app.')}`);
|
|
56
|
+
}
|
|
57
|
+
console.log();
|
|
58
|
+
}
|
|
59
|
+
const ref = projectRef(projectArg, projectId);
|
|
60
|
+
writer.ok(withSetupGuidance(result), {
|
|
61
|
+
summary: `Mobile feedback ready for "${result.project.name}"`,
|
|
62
|
+
breadcrumbs: [
|
|
63
|
+
{ action: 'Check connection', cmd: `feedbackbasket mobile verify ${ref}` },
|
|
64
|
+
{ action: 'View mobile status', cmd: `feedbackbasket mobile status ${ref}` },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
mobile
|
|
69
|
+
.command('bundle-ids [project]')
|
|
70
|
+
.description('Add or remove allowed iOS bundle IDs')
|
|
71
|
+
.option('--add <bundle-id>', 'Bundle ID to add (repeatable)', collect, [])
|
|
72
|
+
.option('--remove <bundle-id>', 'Bundle ID to remove (repeatable)', collect, [])
|
|
73
|
+
.action(async (projectArg, opts) => {
|
|
74
|
+
const writer = getWriter();
|
|
75
|
+
const client = requireClient();
|
|
76
|
+
const additions = validateBundleIds(opts.add);
|
|
77
|
+
const removals = validateBundleIds(opts.remove);
|
|
78
|
+
if (additions.length === 0 && removals.length === 0) {
|
|
79
|
+
throw errUsage('Pass at least one --add or --remove bundle ID', 'Example: feedbackbasket mobile bundle-ids myapp --add com.example.app');
|
|
80
|
+
}
|
|
81
|
+
const overlap = additions.filter((bundleId) => removals.includes(bundleId));
|
|
82
|
+
if (overlap.length > 0) {
|
|
83
|
+
throw errUsage(`The same bundle ID cannot be added and removed: ${overlap.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
86
|
+
const result = await client.updateMobileIntegration(projectId, {
|
|
87
|
+
addBundleIds: additions,
|
|
88
|
+
removeBundleIds: removals,
|
|
89
|
+
});
|
|
90
|
+
if (!writer.isMachineOutput()) {
|
|
91
|
+
console.log(` ${brand.success('✓')} Allowed bundle IDs updated for ${brand.bold(result.project.name)}`);
|
|
92
|
+
console.log();
|
|
93
|
+
}
|
|
94
|
+
writer.ok(result, {
|
|
95
|
+
summary: `Updated mobile bundle IDs for "${result.project.name}"`,
|
|
96
|
+
breadcrumbs: [
|
|
97
|
+
{ action: 'View mobile status', cmd: `feedbackbasket mobile status ${projectRef(projectArg, projectId)}` },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
mobile
|
|
102
|
+
.command('conversations [project]')
|
|
103
|
+
.description('Enable or disable in-app follow-up replies')
|
|
104
|
+
.option('--enable', 'Let users continue feedback conversations in the app')
|
|
105
|
+
.option('--disable', 'Keep in-app team replies read-only')
|
|
106
|
+
.action(async (projectArg, opts) => {
|
|
107
|
+
if (Boolean(opts.enable) === Boolean(opts.disable)) {
|
|
108
|
+
throw errUsage('Choose either --enable or --disable');
|
|
109
|
+
}
|
|
110
|
+
const writer = getWriter();
|
|
111
|
+
const client = requireClient();
|
|
112
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
113
|
+
const allowVisitorReplies = Boolean(opts.enable);
|
|
114
|
+
const result = await client.updateMobileIntegration(projectId, { allowVisitorReplies });
|
|
115
|
+
if (!writer.isMachineOutput()) {
|
|
116
|
+
console.log(` ${brand.success('✓')} In-app conversations ${allowVisitorReplies ? 'enabled' : 'disabled'} for ${brand.bold(result.project.name)}`);
|
|
117
|
+
console.log();
|
|
118
|
+
}
|
|
119
|
+
writer.ok(result, {
|
|
120
|
+
summary: `${allowVisitorReplies ? 'Enabled' : 'Disabled'} in-app follow-up replies for "${result.project.name}"`,
|
|
121
|
+
breadcrumbs: [
|
|
122
|
+
{ action: 'View mobile status', cmd: `feedbackbasket mobile status ${projectRef(projectArg, projectId)}` },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
mobile
|
|
127
|
+
.command('verify [project]')
|
|
128
|
+
.description('Verify that the SDK has connected from the expected app')
|
|
129
|
+
.option('--bundle-id <bundle-id>', 'Expected bundle ID')
|
|
130
|
+
.option('--wait <seconds>', 'Wait for the first matching heartbeat (maximum 300 seconds)', '0')
|
|
131
|
+
.action(async (projectArg, opts) => {
|
|
132
|
+
const writer = getWriter();
|
|
133
|
+
const client = requireClient();
|
|
134
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
135
|
+
const expectedBundleId = opts.bundleId
|
|
136
|
+
? validateBundleIds([opts.bundleId])[0]
|
|
137
|
+
: undefined;
|
|
138
|
+
const waitSeconds = parseWaitSeconds(opts.wait);
|
|
139
|
+
const deadline = Date.now() + waitSeconds * 1_000;
|
|
140
|
+
let result = await client.getMobileIntegration(projectId);
|
|
141
|
+
while (!isMobileConnectionVerified(result, expectedBundleId) && Date.now() < deadline) {
|
|
142
|
+
await sleep(Math.min(VERIFY_POLL_INTERVAL_MS, deadline - Date.now()));
|
|
143
|
+
result = await client.getMobileIntegration(projectId);
|
|
144
|
+
}
|
|
145
|
+
const verified = isMobileConnectionVerified(result, expectedBundleId);
|
|
146
|
+
const verification = {
|
|
147
|
+
verified,
|
|
148
|
+
expectedBundleId: expectedBundleId ?? null,
|
|
149
|
+
project: result.project,
|
|
150
|
+
integration: result.integration,
|
|
151
|
+
};
|
|
152
|
+
if (!writer.isMachineOutput())
|
|
153
|
+
renderVerification(verification);
|
|
154
|
+
writer.ok(verification, {
|
|
155
|
+
summary: verified
|
|
156
|
+
? `Verified mobile connection for "${result.project.name}"`
|
|
157
|
+
: `Mobile connection not yet verified for "${result.project.name}"`,
|
|
158
|
+
notice: verified
|
|
159
|
+
? undefined
|
|
160
|
+
: 'Build and launch the app, then run this command again with --wait 120.',
|
|
161
|
+
});
|
|
162
|
+
if (!verified)
|
|
163
|
+
process.exitCode = 2;
|
|
164
|
+
});
|
|
165
|
+
mobile
|
|
166
|
+
.command('disable [project]')
|
|
167
|
+
.description('Disable mobile feedback without changing the website widget')
|
|
168
|
+
.option('--yes', 'Confirm disabling mobile feedback')
|
|
169
|
+
.action(async (projectArg, opts) => {
|
|
170
|
+
const writer = getWriter();
|
|
171
|
+
const client = requireClient();
|
|
172
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
173
|
+
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');
|
|
174
|
+
const result = await client.updateMobileIntegration(projectId, { enabled: false });
|
|
175
|
+
if (!writer.isMachineOutput()) {
|
|
176
|
+
console.log(` ${brand.success('✓')} Mobile feedback disabled for ${brand.bold(result.project.name)}`);
|
|
177
|
+
console.log();
|
|
178
|
+
}
|
|
179
|
+
writer.ok(result, {
|
|
180
|
+
summary: `Disabled mobile feedback for "${result.project.name}"`,
|
|
181
|
+
breadcrumbs: [
|
|
182
|
+
{ action: 'Re-enable mobile feedback', cmd: `feedbackbasket mobile setup ${projectRef(projectArg, projectId)}` },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
mobile
|
|
187
|
+
.command('rotate-key [project]')
|
|
188
|
+
.description('Rotate the publishable mobile key (existing app builds will stop working)')
|
|
189
|
+
.option('--yes', 'Confirm key rotation')
|
|
190
|
+
.option('--include-publishable-key', 'Include the newly generated publishable key')
|
|
191
|
+
.action(async (projectArg, opts) => {
|
|
192
|
+
const writer = getWriter();
|
|
193
|
+
const client = requireClient();
|
|
194
|
+
const projectId = await resolveProjectId(client, projectArg);
|
|
195
|
+
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');
|
|
196
|
+
const result = await client.rotateMobileProjectKey(projectId, Boolean(opts.includePublishableKey));
|
|
197
|
+
if (!writer.isMachineOutput()) {
|
|
198
|
+
console.log(` ${brand.warning('!')} Mobile project key rotated for ${brand.bold(result.project.name)}`);
|
|
199
|
+
console.log(' Update and release every installed app that used the previous key.');
|
|
200
|
+
console.log();
|
|
201
|
+
}
|
|
202
|
+
writer.ok(result, {
|
|
203
|
+
summary: `Rotated mobile project key for "${result.project.name}"`,
|
|
204
|
+
notice: 'Existing app builds using the previous key can no longer submit feedback.',
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
return mobile;
|
|
208
|
+
}
|
|
209
|
+
function requireClient() {
|
|
210
|
+
const manager = new AuthManager();
|
|
211
|
+
const token = manager.resolveToken();
|
|
212
|
+
if (!token)
|
|
213
|
+
throw errAuth();
|
|
214
|
+
return new FeedbackBasketClient(token, loadConfig().baseUrl);
|
|
215
|
+
}
|
|
216
|
+
async function resolveProjectId(client, projectArg) {
|
|
217
|
+
if (projectArg)
|
|
218
|
+
return (await resolveProject(client, projectArg)).id;
|
|
219
|
+
const config = loadConfig();
|
|
220
|
+
if (config.defaultProject)
|
|
221
|
+
return config.defaultProject;
|
|
222
|
+
throw errUsage('Project is required. Pass a project name/ID or set a default.', 'feedbackbasket mobile status <project>');
|
|
223
|
+
}
|
|
224
|
+
function collect(value, previous) {
|
|
225
|
+
return [...previous, value];
|
|
226
|
+
}
|
|
227
|
+
export function projectRef(projectArg, projectId) {
|
|
228
|
+
const ref = projectArg?.trim();
|
|
229
|
+
if (!ref || !SAFE_PROJECT_REF_PATTERN.test(ref))
|
|
230
|
+
return projectId;
|
|
231
|
+
return ref.includes(' ') ? `"${ref}"` : ref;
|
|
232
|
+
}
|
|
233
|
+
export function validateBundleIds(bundleIds) {
|
|
234
|
+
const normalized = Array.from(new Set(bundleIds.map((value) => value.trim()).filter(Boolean)));
|
|
235
|
+
const invalid = normalized.find((bundleId) => !BUNDLE_ID_PATTERN.test(bundleId));
|
|
236
|
+
if (invalid) {
|
|
237
|
+
throw errUsage(`Invalid bundle ID "${invalid}"`, 'Bundle IDs must look like com.example.app');
|
|
238
|
+
}
|
|
239
|
+
if (normalized.length > 20)
|
|
240
|
+
throw errUsage('A project can have at most 20 bundle IDs');
|
|
241
|
+
return normalized;
|
|
242
|
+
}
|
|
243
|
+
export function isMobileConnectionVerified(result, expectedBundleId) {
|
|
244
|
+
const integration = result.integration;
|
|
245
|
+
if (!integration?.enabled || !integration.connection.connected)
|
|
246
|
+
return false;
|
|
247
|
+
return !expectedBundleId || integration.connection.bundleId === expectedBundleId;
|
|
248
|
+
}
|
|
249
|
+
function parseWaitSeconds(value) {
|
|
250
|
+
const seconds = Number(value);
|
|
251
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > MAX_VERIFY_WAIT_SECONDS) {
|
|
252
|
+
throw errUsage(`--wait must be a whole number between 0 and ${MAX_VERIFY_WAIT_SECONDS}`);
|
|
253
|
+
}
|
|
254
|
+
return seconds;
|
|
255
|
+
}
|
|
256
|
+
function sleep(milliseconds) {
|
|
257
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
|
|
258
|
+
}
|
|
259
|
+
async function requireDestructiveConfirmation(writer, confirmedByFlag, question, agentHint) {
|
|
260
|
+
if (confirmedByFlag)
|
|
261
|
+
return;
|
|
262
|
+
if (writer.isMachineOutput() || !process.stdin.isTTY) {
|
|
263
|
+
throw errUsage(agentHint);
|
|
264
|
+
}
|
|
265
|
+
if (!(await confirm(` ${question}`, false))) {
|
|
266
|
+
throw errUsage('Action cancelled');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function withSetupGuidance(result) {
|
|
270
|
+
return {
|
|
271
|
+
...result,
|
|
272
|
+
setup: {
|
|
273
|
+
publishableKeyIncluded: result.integration?.publishableKeyIncluded ?? false,
|
|
274
|
+
supportedIntegrations: ['swiftui', 'uikit', 'react-native', 'flutter', 'hosted-form'],
|
|
275
|
+
nextSteps: result.integration?.publishableKeyIncluded
|
|
276
|
+
? [
|
|
277
|
+
'Detect the mobile framework and minimum supported platform version.',
|
|
278
|
+
'Configure the app with the returned publishable mobile project key.',
|
|
279
|
+
'Add an accessible Send feedback action to an existing Settings, Help, or Support screen.',
|
|
280
|
+
'Build and launch the app, then verify its SDK heartbeat.',
|
|
281
|
+
]
|
|
282
|
+
: [
|
|
283
|
+
'Re-run setup with --include-publishable-key when you are ready to configure the app.',
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function renderMobileStatus(result) {
|
|
289
|
+
console.log(brand.bold(`Mobile feedback — ${result.project.name}`));
|
|
290
|
+
console.log(divider(54));
|
|
291
|
+
if (!result.integration) {
|
|
292
|
+
console.log(brand.muted(' Mobile feedback is not enabled.'));
|
|
293
|
+
console.log();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const integration = result.integration;
|
|
297
|
+
console.log(` ${brand.label('Status'.padEnd(18))} ${integration.enabled ? 'Enabled' : 'Disabled'}`);
|
|
298
|
+
console.log(` ${brand.label('Conversations'.padEnd(18))} ${integration.allowVisitorReplies ? 'Enabled' : 'Read-only'}`);
|
|
299
|
+
console.log(` ${brand.label('Publishable key'.padEnd(18))} ${integration.publishableKey}`);
|
|
300
|
+
console.log(` ${brand.label('Bundle IDs'.padEnd(18))} ${integration.bundleIds.join(', ') || 'Any bundle ID'}`);
|
|
301
|
+
console.log(` ${brand.label('Connection'.padEnd(18))} ${integration.connection.connected ? 'Connected' : 'Waiting for first heartbeat'}`);
|
|
302
|
+
if (integration.connection.lastSeenAt) {
|
|
303
|
+
console.log(` ${brand.label('Last seen'.padEnd(18))} ${integration.connection.lastSeenAt}`);
|
|
304
|
+
}
|
|
305
|
+
if (integration.connection.bundleId) {
|
|
306
|
+
console.log(` ${brand.label('Last bundle'.padEnd(18))} ${integration.connection.bundleId}`);
|
|
307
|
+
}
|
|
308
|
+
console.log();
|
|
309
|
+
}
|
|
310
|
+
function renderVerification(result) {
|
|
311
|
+
const icon = result.verified ? brand.success('✓') : brand.warning('!');
|
|
312
|
+
console.log(` ${icon} ${result.verified ? 'Mobile SDK connection verified' : 'Mobile SDK connection not yet verified'}`);
|
|
313
|
+
if (result.expectedBundleId)
|
|
314
|
+
console.log(` ${brand.muted(`Expected bundle: ${result.expectedBundleId}`)}`);
|
|
315
|
+
if (result.integration?.connection.lastSeenAt) {
|
|
316
|
+
console.log(` ${brand.muted(`Last heartbeat: ${result.integration.connection.lastSeenAt}`)}`);
|
|
317
|
+
}
|
|
318
|
+
console.log();
|
|
319
|
+
}
|
|
@@ -72,6 +72,8 @@ export function createWidgetCommand(getWriter) {
|
|
|
72
72
|
.option('--no-hide-email-when-prefilled', 'Show the email field even when userEmail is prefilled')
|
|
73
73
|
.option('--allow-attachments', 'Allow image attachments')
|
|
74
74
|
.option('--no-allow-attachments', 'Disable image attachments')
|
|
75
|
+
.option('--allow-visitor-replies', 'Let visitors continue feedback conversations')
|
|
76
|
+
.option('--no-allow-visitor-replies', 'Keep widget replies read-only for visitors')
|
|
75
77
|
.option('--icon-only', 'Show only the icon, no label')
|
|
76
78
|
.option('--no-icon-only', 'Show both icon and label')
|
|
77
79
|
.option('--show-icon', 'Show an icon next to the label')
|
|
@@ -94,7 +96,7 @@ export function createWidgetCommand(getWriter) {
|
|
|
94
96
|
opts.buttonRadius || opts.buttonSize || opts.icon ||
|
|
95
97
|
opts.emailRequired !== undefined || opts.showEmail !== undefined ||
|
|
96
98
|
opts.emailReadOnly !== undefined || opts.hideEmailWhenPrefilled !== undefined ||
|
|
97
|
-
opts.allowAttachments !== undefined || opts.iconOnly !== undefined ||
|
|
99
|
+
opts.allowAttachments !== undefined || opts.allowVisitorReplies !== undefined || opts.iconOnly !== undefined ||
|
|
98
100
|
opts.showIcon !== undefined || opts.showBranding !== undefined ||
|
|
99
101
|
opts.allowConsoleErrors !== undefined || opts.errorTracking !== undefined ||
|
|
100
102
|
opts.zIndex || opts.guided || opts.disableGuided;
|
|
@@ -139,6 +141,8 @@ export function createWidgetCommand(getWriter) {
|
|
|
139
141
|
settings.hideEmailFieldWhenPrefilled = opts.hideEmailWhenPrefilled;
|
|
140
142
|
if (opts.allowAttachments !== undefined)
|
|
141
143
|
settings.allowAttachments = opts.allowAttachments;
|
|
144
|
+
if (opts.allowVisitorReplies !== undefined)
|
|
145
|
+
settings.allowVisitorReplies = opts.allowVisitorReplies;
|
|
142
146
|
if (opts.iconOnly !== undefined)
|
|
143
147
|
settings.iconOnly = opts.iconOnly;
|
|
144
148
|
if (opts.showIcon !== undefined)
|
|
@@ -434,6 +438,7 @@ function renderWidgetSettings(projectName, settings) {
|
|
|
434
438
|
['Email Read Only', String(settings.emailReadOnly ?? false)],
|
|
435
439
|
['Hide Prefilled Email', String(settings.hideEmailFieldWhenPrefilled ?? false)],
|
|
436
440
|
['Attachments', String(settings.allowAttachments ?? true)],
|
|
441
|
+
['Follow-up Replies', String(settings.allowVisitorReplies ?? false)],
|
|
437
442
|
['Guided Flow', String(settings.feedbackFlow?.enabled ?? false)],
|
|
438
443
|
['Intro Message', String(settings.introMessage ?? '')],
|
|
439
444
|
['Success Message', String(settings.successMessage ?? '')],
|
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
|