@kryshtop/bstack 1.0.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/dist/api/http/BrowserStackHttpClient.d.ts +30 -0
  4. package/dist/api/http/BrowserStackHttpClient.js +111 -0
  5. package/dist/api/http/BrowserStackHttpClient.js.map +1 -0
  6. package/dist/api/http/errors.d.ts +11 -0
  7. package/dist/api/http/errors.js +31 -0
  8. package/dist/api/http/errors.js.map +1 -0
  9. package/dist/api/http/multipart.d.ts +13 -0
  10. package/dist/api/http/multipart.js +28 -0
  11. package/dist/api/http/multipart.js.map +1 -0
  12. package/dist/api/normalizers/common.d.ts +7 -0
  13. package/dist/api/normalizers/common.js +106 -0
  14. package/dist/api/normalizers/common.js.map +1 -0
  15. package/dist/api/registry/EndpointRegistry.d.ts +10 -0
  16. package/dist/api/registry/EndpointRegistry.js +34 -0
  17. package/dist/api/registry/EndpointRegistry.js.map +1 -0
  18. package/dist/api/registry/definitions.d.ts +2 -0
  19. package/dist/api/registry/definitions.js +510 -0
  20. package/dist/api/registry/definitions.js.map +1 -0
  21. package/dist/api/registry/types.d.ts +23 -0
  22. package/dist/api/registry/types.js +2 -0
  23. package/dist/api/registry/types.js.map +1 -0
  24. package/dist/auth/AuthService.d.ts +24 -0
  25. package/dist/auth/AuthService.js +100 -0
  26. package/dist/auth/AuthService.js.map +1 -0
  27. package/dist/bin.d.ts +2 -0
  28. package/dist/bin.js +4 -0
  29. package/dist/bin.js.map +1 -0
  30. package/dist/cli/context.d.ts +39 -0
  31. package/dist/cli/context.js +117 -0
  32. package/dist/cli/context.js.map +1 -0
  33. package/dist/cli/output.d.ts +29 -0
  34. package/dist/cli/output.js +143 -0
  35. package/dist/cli/output.js.map +1 -0
  36. package/dist/cli/program.d.ts +4 -0
  37. package/dist/cli/program.js +60 -0
  38. package/dist/cli/program.js.map +1 -0
  39. package/dist/cli/runCli.d.ts +1 -0
  40. package/dist/cli/runCli.js +31 -0
  41. package/dist/cli/runCli.js.map +1 -0
  42. package/dist/commands/auth.d.ts +3 -0
  43. package/dist/commands/auth.js +73 -0
  44. package/dist/commands/auth.js.map +1 -0
  45. package/dist/commands/explorer.d.ts +3 -0
  46. package/dist/commands/explorer.js +86 -0
  47. package/dist/commands/explorer.js.map +1 -0
  48. package/dist/commands/framework.d.ts +3 -0
  49. package/dist/commands/framework.js +474 -0
  50. package/dist/commands/framework.js.map +1 -0
  51. package/dist/config/paths.d.ts +5 -0
  52. package/dist/config/paths.js +22 -0
  53. package/dist/config/paths.js.map +1 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.js +16 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/menus/interactiveMenu.d.ts +2 -0
  58. package/dist/menus/interactiveMenu.js +1106 -0
  59. package/dist/menus/interactiveMenu.js.map +1 -0
  60. package/dist/menus/screenModel.d.ts +12 -0
  61. package/dist/menus/screenModel.js +152 -0
  62. package/dist/menus/screenModel.js.map +1 -0
  63. package/dist/prompts/authPrompts.d.ts +10 -0
  64. package/dist/prompts/authPrompts.js +46 -0
  65. package/dist/prompts/authPrompts.js.map +1 -0
  66. package/dist/services/ResourceService.d.ts +25 -0
  67. package/dist/services/ResourceService.js +80 -0
  68. package/dist/services/ResourceService.js.map +1 -0
  69. package/dist/services/frameworkConfigs.d.ts +16 -0
  70. package/dist/services/frameworkConfigs.js +108 -0
  71. package/dist/services/frameworkConfigs.js.map +1 -0
  72. package/dist/storage/CredentialStore.d.ts +11 -0
  73. package/dist/storage/CredentialStore.js +2 -0
  74. package/dist/storage/CredentialStore.js.map +1 -0
  75. package/dist/storage/FileCredentialStores.d.ts +18 -0
  76. package/dist/storage/FileCredentialStores.js +74 -0
  77. package/dist/storage/FileCredentialStores.js.map +1 -0
  78. package/dist/storage/FileStorage.d.ts +3 -0
  79. package/dist/storage/FileStorage.js +18 -0
  80. package/dist/storage/FileStorage.js.map +1 -0
  81. package/dist/storage/KeytarCredentialStore.d.ts +9 -0
  82. package/dist/storage/KeytarCredentialStore.js +40 -0
  83. package/dist/storage/KeytarCredentialStore.js.map +1 -0
  84. package/dist/storage/SessionRepository.d.ts +28 -0
  85. package/dist/storage/SessionRepository.js +151 -0
  86. package/dist/storage/SessionRepository.js.map +1 -0
  87. package/dist/types/domain.d.ts +85 -0
  88. package/dist/types/domain.js +2 -0
  89. package/dist/types/domain.js.map +1 -0
  90. package/dist/utils/constants.d.ts +10 -0
  91. package/dist/utils/constants.js +11 -0
  92. package/dist/utils/constants.js.map +1 -0
  93. package/dist/utils/errors.d.ts +2 -0
  94. package/dist/utils/errors.js +12 -0
  95. package/dist/utils/errors.js.map +1 -0
  96. package/dist/utils/files.d.ts +4 -0
  97. package/dist/utils/files.js +28 -0
  98. package/dist/utils/files.js.map +1 -0
  99. package/dist/utils/json.d.ts +2 -0
  100. package/dist/utils/json.js +10 -0
  101. package/dist/utils/json.js.map +1 -0
  102. package/dist/utils/mask.d.ts +2 -0
  103. package/dist/utils/mask.js +10 -0
  104. package/dist/utils/mask.js.map +1 -0
  105. package/dist/utils/query.d.ts +1 -0
  106. package/dist/utils/query.js +17 -0
  107. package/dist/utils/query.js.map +1 -0
  108. package/package.json +92 -0
@@ -0,0 +1,1106 @@
1
+ import { confirm, input, select } from '@inquirer/prompts';
2
+ import { normalizeArtifactCollection, normalizeSessionCollection } from '../api/normalizers/common.js';
3
+ import { promptForLogin } from '../prompts/authPrompts.js';
4
+ import { frameworkDescriptors, getFrameworkDescriptor, validateUploadPath } from '../services/frameworkConfigs.js';
5
+ import { prettyJson } from '../utils/json.js';
6
+ import { describeResource, frameworkHints, } from './screenModel.js';
7
+ export async function runInteractiveMenu(runtime) {
8
+ const initialStatus = await runtime.getSessionStatus();
9
+ const uiState = {
10
+ activeFramework: initialStatus.currentFramework ?? 'appium',
11
+ needsValidationRefresh: initialStatus.loggedIn,
12
+ };
13
+ while (true) {
14
+ const snapshot = await runtime.getDashboardSnapshot({
15
+ refreshValidation: uiState.needsValidationRefresh,
16
+ });
17
+ uiState.needsValidationRefresh = false;
18
+ if (!snapshot.sessionStatus.loggedIn) {
19
+ const next = await runWelcomeScreen(runtime);
20
+ if (next === 'exit') {
21
+ return;
22
+ }
23
+ if (next === 'connected') {
24
+ uiState.needsValidationRefresh = true;
25
+ }
26
+ continue;
27
+ }
28
+ const action = await runDashboardScreen(runtime, snapshot, uiState);
29
+ if (action === 'exit') {
30
+ return;
31
+ }
32
+ try {
33
+ switch (action) {
34
+ case 'dashboard':
35
+ uiState.needsValidationRefresh = true;
36
+ break;
37
+ case 'upload':
38
+ await runUploadScreen(runtime, uiState);
39
+ break;
40
+ case 'artifacts':
41
+ await runArtifactsScreen(runtime, uiState);
42
+ break;
43
+ case 'builds':
44
+ await runBuildsScreen(runtime, uiState);
45
+ break;
46
+ case 'sessions':
47
+ await runSessionsScreen(runtime, uiState);
48
+ break;
49
+ case 'media':
50
+ await runMediaScreen(runtime);
51
+ break;
52
+ case 'frameworks':
53
+ await runFrameworksScreen(runtime, uiState);
54
+ break;
55
+ case 'tools':
56
+ await runToolsScreen(runtime, uiState);
57
+ break;
58
+ case 'settings':
59
+ await runSettingsScreen(runtime, uiState);
60
+ uiState.needsValidationRefresh = true;
61
+ break;
62
+ case 'help':
63
+ await runHelpScreen(runtime);
64
+ break;
65
+ }
66
+ }
67
+ catch (error) {
68
+ runtime.output.clear();
69
+ runtime.output.title('Action interrupted', 'The current workflow could not be completed.');
70
+ runtime.output.banner('Something failed', error instanceof Error ? error.message : String(error), 'error');
71
+ runtime.output.lines([
72
+ 'What you can do next:',
73
+ '1. Retry the action',
74
+ '2. Refresh account status',
75
+ '3. Open Tools for raw endpoint details',
76
+ ]);
77
+ await pause('Press Enter to return to the dashboard');
78
+ }
79
+ }
80
+ }
81
+ async function runWelcomeScreen(runtime) {
82
+ runtime.output.clear();
83
+ runtime.output.title('BrowserStack App Automate CLI', 'Manage uploads, builds, sessions, and media from your terminal.');
84
+ runtime.output.lines([
85
+ 'This tool helps you upload apps and test artifacts, browse recent automation runs, inspect execution sessions, and manage reusable media assets.',
86
+ 'Use interactive mode for guided workflows, or switch to command mode later for CI and scripting.',
87
+ '',
88
+ 'Main workflows:',
89
+ '- Upload app binaries and test suites/packages',
90
+ '- Review builds and session details',
91
+ '- Manage shared media files for test runs',
92
+ ]);
93
+ runtime.output.divider();
94
+ runtime.output.footerHints([
95
+ '[↑↓] Navigate',
96
+ '[Enter] Select',
97
+ '[Esc] Cancel',
98
+ '[Q] Quit',
99
+ ]);
100
+ const choice = await select({
101
+ message: 'Welcome',
102
+ choices: [
103
+ {
104
+ name: 'Connect BrowserStack account',
105
+ value: 'connect',
106
+ description: 'Primary step. Validate credentials and start from a signed-in dashboard.',
107
+ },
108
+ {
109
+ name: 'Use environment variables',
110
+ value: 'env',
111
+ description: 'Connect via BSTACK_USERNAME and BSTACK_ACCESS_KEY without saving credentials.',
112
+ },
113
+ {
114
+ name: 'Help',
115
+ value: 'help',
116
+ description: 'Learn what this tool does and how authentication works.',
117
+ },
118
+ {
119
+ name: 'Quit',
120
+ value: 'quit',
121
+ description: 'Exit the interactive terminal workspace.',
122
+ },
123
+ ],
124
+ });
125
+ if (choice === 'quit') {
126
+ return 'exit';
127
+ }
128
+ if (choice === 'help') {
129
+ await runHelpScreen(runtime);
130
+ return 'continue';
131
+ }
132
+ if (choice === 'env') {
133
+ runtime.output.clear();
134
+ runtime.output.title('Use environment variables', 'Connect without storing credentials locally.');
135
+ runtime.output.lines([
136
+ 'Set these in your shell before launching the CLI:',
137
+ 'export BSTACK_USERNAME="your-browserstack-username"',
138
+ 'export BSTACK_ACCESS_KEY="your-browserstack-access-key"',
139
+ '',
140
+ 'Then restart this interactive mode or choose Refresh account status from Settings later.',
141
+ ]);
142
+ await pause('Press Enter to return');
143
+ return 'continue';
144
+ }
145
+ const prompted = await promptForLogin();
146
+ await runtime.runWithSpinner('Checking BrowserStack connection…', async () => {
147
+ await runtime.auth.login(prompted);
148
+ });
149
+ await runOnboarding(runtime);
150
+ return 'connected';
151
+ }
152
+ async function runDashboardScreen(runtime, snapshot, uiState) {
153
+ renderDashboard(runtime, snapshot, uiState);
154
+ const status = snapshot.sessionStatus;
155
+ const quickActionPrefix = status.connectionState === 'invalid' ? 'Recovery' : 'Quick action';
156
+ return select({
157
+ message: 'Choose your next task',
158
+ choices: [
159
+ {
160
+ name: `${quickActionPrefix}: Upload`,
161
+ value: 'upload',
162
+ description: 'Send a new app, test suite/package, or media asset to BrowserStack.',
163
+ },
164
+ {
165
+ name: `${quickActionPrefix}: Apps & Packages`,
166
+ value: 'artifacts',
167
+ description: 'Browse uploaded apps and test artifacts, then inspect or remove them.',
168
+ },
169
+ {
170
+ name: `${quickActionPrefix}: Builds`,
171
+ value: 'builds',
172
+ description: 'Open recent runs and inspect overall build status.',
173
+ },
174
+ {
175
+ name: `${quickActionPrefix}: Sessions`,
176
+ value: 'sessions',
177
+ description: 'Inspect test execution sessions, linked logs, and session metadata.',
178
+ },
179
+ {
180
+ name: 'Media',
181
+ value: 'media',
182
+ description: 'Manage reusable media assets for supported test scenarios.',
183
+ },
184
+ {
185
+ name: 'Frameworks',
186
+ value: 'frameworks',
187
+ description: 'Switch your active framework focus and see framework capabilities.',
188
+ },
189
+ {
190
+ name: 'Tools',
191
+ value: 'tools',
192
+ description: 'Search resources, export the last response, or use the raw endpoint explorer.',
193
+ },
194
+ {
195
+ name: 'Settings',
196
+ value: 'settings',
197
+ description: 'Manage account state, reconnect, logout, and other preferences.',
198
+ },
199
+ {
200
+ name: 'Help',
201
+ value: 'help',
202
+ description: 'See common workflows, keyboard hints, auth behavior, and command-mode tips.',
203
+ },
204
+ {
205
+ name: 'Exit',
206
+ value: 'exit',
207
+ description: 'Leave the interactive BrowserStack workspace.',
208
+ },
209
+ ],
210
+ });
211
+ }
212
+ function renderDashboard(runtime, snapshot, uiState) {
213
+ const { sessionStatus, authStatus, lastResponse } = snapshot;
214
+ runtime.output.clear();
215
+ runtime.output.title('BrowserStack App Automate CLI', 'Manage uploads, builds, sessions, and media from your terminal.');
216
+ if (sessionStatus.connectionState === 'invalid' && sessionStatus.lastValidationError) {
217
+ runtime.output.banner('Credential attention needed', `The saved or environment-provided credentials could not be validated.\nNext step: open Settings to reconnect, switch account, or inspect auth details.`, 'warning');
218
+ }
219
+ runtime.output.lines([
220
+ `${runtime.output.badge(humanStateLabel(sessionStatus.connectionState), sessionStatus.connectionState ?? 'disconnected')} ${statusSentence(sessionStatus)}`,
221
+ '',
222
+ ]);
223
+ runtime.output.section('Status');
224
+ runtime.output.kv('Connected as:', sessionStatus.username ?? 'No account connected');
225
+ runtime.output.kv('Auth source:', humanAuthSource(sessionStatus.authSource));
226
+ runtime.output.kv('Current framework:', frameworkLabel(uiState.activeFramework));
227
+ runtime.output.kv('Last successful API check:', sessionStatus.lastValidatedAt ? formatTime(sessionStatus.lastValidatedAt) : 'Not checked yet');
228
+ runtime.output.kv('Last action:', sessionStatus.lastActionLabel
229
+ ? `${sessionStatus.lastActionLabel} · ${formatTime(sessionStatus.lastActionAt)}`
230
+ : 'No interactive actions yet');
231
+ runtime.output.kv('API health:', authStatus?.valid
232
+ ? `Healthy · plan ${authStatus.planName ?? 'available'}`
233
+ : sessionStatus.connectionState === 'invalid'
234
+ ? 'Needs attention'
235
+ : 'Ready');
236
+ runtime.output.divider();
237
+ runtime.output.section('Quick actions');
238
+ runtime.output.lines([
239
+ '- Upload new app or test artifact',
240
+ '- Browse recent apps and packages',
241
+ '- Open recent builds',
242
+ '- Open recent sessions',
243
+ '- Upload media for test scenarios',
244
+ ]);
245
+ runtime.output.divider();
246
+ runtime.output.section('Recent context');
247
+ if (lastResponse) {
248
+ runtime.output.lines([
249
+ `Last response: ${lastResponse.command ?? `${lastResponse.framework ?? 'unknown'} ${lastResponse.operation ?? ''}`}`.trim(),
250
+ `Captured: ${formatTime(lastResponse.at)}`,
251
+ `Scope: ${[lastResponse.framework, lastResponse.resource, lastResponse.operation]
252
+ .filter(Boolean)
253
+ .join(' / ') || 'N/A'}`,
254
+ ]);
255
+ }
256
+ else {
257
+ runtime.output.emptyState('No recent response yet', 'Run an upload, list, build, or session action to capture reusable output for later export.', 'Recommended next step: open Upload or Apps & Packages.');
258
+ }
259
+ runtime.output.divider();
260
+ runtime.output.footerHints([
261
+ '[↑↓] Navigate',
262
+ '[Enter] Open',
263
+ 'Dashboard first',
264
+ 'Settings for account recovery',
265
+ 'Tools for advanced actions',
266
+ ]);
267
+ }
268
+ async function runUploadScreen(runtime, uiState) {
269
+ const choice = await renderActionScreen(runtime, 'Upload', 'Send a new app, test suite/package, or media asset to BrowserStack.', [
270
+ 'Typical actions: upload app, upload test suite/package, upload media.',
271
+ 'Auth required: yes.',
272
+ 'Supports local file uploads and public URLs where the framework allows them.',
273
+ ], [
274
+ {
275
+ name: 'Upload app',
276
+ value: 'app',
277
+ description: 'Send a local APK/AAB/XAPK/IPA or a public URL to BrowserStack.',
278
+ },
279
+ {
280
+ name: 'Upload test suite',
281
+ value: 'suite',
282
+ description: 'Send framework-specific test artifacts such as ZIP or APK test bundles.',
283
+ },
284
+ {
285
+ name: 'Upload Flutter iOS package',
286
+ value: 'package',
287
+ description: 'Send a Flutter iOS package archive for build execution later.',
288
+ },
289
+ {
290
+ name: 'Upload media',
291
+ value: 'media',
292
+ description: 'Upload a shared media file and receive a reusable media_url.',
293
+ },
294
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
295
+ ]);
296
+ if (choice === 'back') {
297
+ return;
298
+ }
299
+ if (choice === 'media') {
300
+ await runMediaScreen(runtime);
301
+ return;
302
+ }
303
+ const resource = choice === 'app' ? 'apps' : choice === 'suite' ? 'test-suites' : 'test-packages';
304
+ const framework = await chooseFrameworkForResource(runtime, resource, uiState.activeFramework);
305
+ uiState.activeFramework = framework;
306
+ await runtime.setCurrentFramework(framework);
307
+ runtime.output.clear();
308
+ const descriptor = getFrameworkDescriptor(framework);
309
+ runtime.output.title('Upload', `${descriptor?.label ?? framework} · ${describeResource(resource)}`);
310
+ runtime.output.lines([
311
+ `Supports: ${uploadSupports(framework, resource)}`,
312
+ 'Typical output: URL/ID, custom_id, uploaded_at, expiry, and shareable references where supported.',
313
+ 'Tip: use custom_id when you want a stable human-readable label for reuse later.',
314
+ ]);
315
+ const source = await select({
316
+ message: 'Upload source',
317
+ choices: [
318
+ { name: 'Local file', value: 'file', description: 'Upload a file from your machine or CI workspace.' },
319
+ { name: 'Public URL', value: 'url', description: 'Let BrowserStack fetch a public artifact URL.' },
320
+ ],
321
+ });
322
+ const pathOrUrl = await input({
323
+ message: source === 'file' ? 'File path' : 'Public URL',
324
+ });
325
+ const customId = await input({
326
+ message: 'Custom ID (optional)',
327
+ default: '',
328
+ });
329
+ if (source === 'file') {
330
+ const warnings = validateUploadPath(framework, resource, pathOrUrl);
331
+ if (warnings.length > 0) {
332
+ runtime.output.warning(warnings.join('\n'));
333
+ }
334
+ }
335
+ const service = await runtime.getResourceService();
336
+ const response = await runtime.runWithSpinner('Uploading asset…', async () => service.execute({
337
+ framework,
338
+ resource,
339
+ operation: 'upload',
340
+ filePath: source === 'file' ? pathOrUrl : undefined,
341
+ url: source === 'url' ? pathOrUrl : undefined,
342
+ fields: { custom_id: customId || undefined },
343
+ }));
344
+ await runtime.saveLastResponse({
345
+ command: `interactive upload ${resource}`,
346
+ framework,
347
+ resource,
348
+ operation: 'upload',
349
+ payload: response,
350
+ });
351
+ runtime.output.clear();
352
+ runtime.output.title('Upload complete', 'BrowserStack accepted the artifact.');
353
+ runtime.output.emit(response);
354
+ await pause('Press Enter to return to the dashboard');
355
+ }
356
+ async function runArtifactsScreen(runtime, uiState) {
357
+ const choice = await renderActionScreen(runtime, 'Apps & Packages', 'Browse, search, and remove uploaded apps or test artifacts.', [
358
+ 'Works with uploaded apps, test suites, and Flutter iOS test packages.',
359
+ 'Typical actions: recent uploads, filter by custom ID or scope, inspect IDs, delete outdated items.',
360
+ 'Auth required: yes.',
361
+ ], [
362
+ { name: 'Browse uploaded apps', value: 'apps', description: 'List recent app binaries for a chosen framework.' },
363
+ { name: 'Browse test suites', value: 'suites', description: 'List recent test suites for a chosen framework.' },
364
+ {
365
+ name: 'Browse Flutter iOS packages',
366
+ value: 'packages',
367
+ description: 'List recent Flutter iOS test packages.',
368
+ },
369
+ {
370
+ name: 'Delete uploaded item',
371
+ value: 'delete',
372
+ description: 'Remove a previously uploaded app, suite, package, or media item with confirmation.',
373
+ },
374
+ {
375
+ name: 'Search/filter uploads',
376
+ value: 'search',
377
+ description: 'Search by custom_id, name, or URL fragment.',
378
+ },
379
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
380
+ ]);
381
+ if (choice === 'back') {
382
+ return;
383
+ }
384
+ if (choice === 'search') {
385
+ await runSearchMenu(runtime, uiState.activeFramework);
386
+ return;
387
+ }
388
+ if (choice === 'delete') {
389
+ await runDeleteWorkflow(runtime, uiState);
390
+ return;
391
+ }
392
+ const resource = choice === 'apps' ? 'apps' : choice === 'suites' ? 'test-suites' : 'test-packages';
393
+ const framework = await chooseFrameworkForResource(runtime, resource, uiState.activeFramework);
394
+ uiState.activeFramework = framework;
395
+ await runtime.setCurrentFramework(framework);
396
+ const customId = await input({ message: 'Custom ID filter (optional)', default: '' });
397
+ const scope = framework === 'appium' || framework === 'media'
398
+ ? await select({
399
+ message: 'Scope',
400
+ choices: [
401
+ { name: 'User', value: 'user', description: 'Only uploads from the current account.' },
402
+ { name: 'Group', value: 'group', description: 'Uploads available to the BrowserStack group.' },
403
+ ],
404
+ })
405
+ : await select({
406
+ message: 'Scope',
407
+ choices: [
408
+ { name: 'User', value: 'user', description: 'Only uploads from the current account.' },
409
+ { name: 'Group', value: 'group', description: 'Ask the API for group-scoped uploads where supported.' },
410
+ ],
411
+ });
412
+ const service = await runtime.getResourceService();
413
+ const operation = (framework === 'appium' || framework === 'media') && scope === 'group' ? 'list-group' : 'list';
414
+ const response = await runtime.runWithSpinner('Loading uploaded artifacts…', async () => service.execute({
415
+ framework,
416
+ resource,
417
+ operation,
418
+ query: {
419
+ custom_id: customId || undefined,
420
+ scope,
421
+ limit: 20,
422
+ },
423
+ }));
424
+ const items = normalizeArtifactCollection(response);
425
+ await runtime.saveLastResponse({
426
+ command: `interactive list ${resource}`,
427
+ framework,
428
+ resource,
429
+ operation,
430
+ payload: response,
431
+ });
432
+ runtime.output.clear();
433
+ runtime.output.title('Apps & Packages', `${frameworkLabel(framework)} · ${describeResource(resource)}`);
434
+ if (items.length === 0) {
435
+ runtime.output.emptyState('No uploaded items found yet', 'There are no matching uploads for the selected framework and filters.', 'Recommended next step: open Upload to send your first artifact.');
436
+ }
437
+ else {
438
+ runtime.output.emit(items, () => runtime.output.tableFromArtifacts(items));
439
+ }
440
+ await pause('Press Enter to return');
441
+ }
442
+ async function runBuildsScreen(runtime, uiState) {
443
+ const choice = await renderActionScreen(runtime, 'Builds', 'Review recent automation runs and inspect their status.', [
444
+ 'Use builds when you want a run-level summary before opening sessions.',
445
+ 'Typical actions: list recent builds, inspect build details, stop a running v2 build.',
446
+ 'Auth required: yes.',
447
+ ], [
448
+ { name: 'Recent builds', value: 'list', description: 'Open recent BrowserStack build summaries.' },
449
+ { name: 'Build details', value: 'details', description: 'Inspect one build by ID.' },
450
+ { name: 'Stop running build', value: 'stop', description: 'Stop a currently running v2 build.' },
451
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
452
+ ]);
453
+ if (choice === 'back') {
454
+ return;
455
+ }
456
+ const framework = await chooseFrameworkForResource(runtime, 'builds', uiState.activeFramework);
457
+ uiState.activeFramework = framework;
458
+ await runtime.setCurrentFramework(framework);
459
+ const service = await runtime.getResourceService();
460
+ if (choice === 'list') {
461
+ const response = await runtime.runWithSpinner('Loading recent builds…', async () => service.listBuilds(framework, { limit: 20 }));
462
+ await runtime.saveLastResponse({
463
+ command: 'interactive builds list',
464
+ framework,
465
+ resource: 'builds',
466
+ operation: 'list',
467
+ payload: response,
468
+ });
469
+ runtime.output.clear();
470
+ runtime.output.title('Builds', `${frameworkLabel(framework)} · recent builds`);
471
+ if (response.length === 0) {
472
+ runtime.output.emptyState('No builds found yet', 'There are no recent builds for the selected framework.', 'Recommended next step: upload artifacts or run a build first.');
473
+ }
474
+ else {
475
+ runtime.output.emit(response, () => runtime.output.tableFromBuilds(response));
476
+ }
477
+ await pause('Press Enter to return');
478
+ return;
479
+ }
480
+ const buildId = await input({ message: 'Build ID' });
481
+ if (choice === 'details') {
482
+ const response = await runtime.runWithSpinner('Loading build details…', async () => service.execute({
483
+ framework,
484
+ resource: 'builds',
485
+ operation: 'get',
486
+ pathParams: framework === 'appium' ? { buildID: buildId } : { buildId },
487
+ }));
488
+ await runtime.saveLastResponse({
489
+ command: 'interactive builds get',
490
+ framework,
491
+ resource: 'builds',
492
+ operation: 'get',
493
+ payload: response,
494
+ });
495
+ runtime.output.clear();
496
+ runtime.output.title('Build details', `${frameworkLabel(framework)} · ${buildId}`);
497
+ runtime.output.emit(response);
498
+ await pause('Press Enter to return');
499
+ return;
500
+ }
501
+ const response = await runtime.runWithSpinner('Stopping build…', async () => service.execute({
502
+ framework,
503
+ resource: 'builds',
504
+ operation: 'stop',
505
+ pathParams: { buildId },
506
+ }));
507
+ runtime.output.clear();
508
+ runtime.output.title('Build stop request sent', `${frameworkLabel(framework)} · ${buildId}`);
509
+ runtime.output.emit(response);
510
+ await pause('Press Enter to return');
511
+ }
512
+ async function runSessionsScreen(runtime, uiState) {
513
+ const choice = await renderActionScreen(runtime, 'Sessions', 'Open detailed execution sessions, logs, and linked artifacts.', [
514
+ 'Best for device-level execution details after you already know the build or session ID.',
515
+ 'Typical actions: list sessions for a build, inspect one session, update Appium session status in command mode.',
516
+ 'Auth required: yes.',
517
+ ], [
518
+ { name: 'Recent sessions for a build', value: 'list', description: 'List sessions under a chosen build.' },
519
+ { name: 'Session details', value: 'details', description: 'Inspect one session by ID.' },
520
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
521
+ ]);
522
+ if (choice === 'back') {
523
+ return;
524
+ }
525
+ const framework = await chooseFrameworkForResource(runtime, 'sessions', uiState.activeFramework);
526
+ uiState.activeFramework = framework;
527
+ await runtime.setCurrentFramework(framework);
528
+ const service = await runtime.getResourceService();
529
+ if (choice === 'list') {
530
+ const buildId = await input({
531
+ message: framework === 'appium'
532
+ ? 'Build ID'
533
+ : 'Build ID (sessions are derived from build details for this framework)',
534
+ });
535
+ const sessions = framework === 'appium'
536
+ ? normalizeSessionCollection(await runtime.runWithSpinner('Loading recent sessions…', async () => service.execute({
537
+ framework,
538
+ resource: 'sessions',
539
+ operation: 'list',
540
+ pathParams: { buildID: buildId },
541
+ query: { limit: 20 },
542
+ })))
543
+ : await runtime.runWithSpinner('Loading sessions from build details…', async () => service.getBuildSessions(framework, buildId));
544
+ await runtime.saveLastResponse({
545
+ command: 'interactive sessions list',
546
+ framework,
547
+ resource: 'sessions',
548
+ operation: 'list',
549
+ payload: sessions,
550
+ });
551
+ runtime.output.clear();
552
+ runtime.output.title('Sessions', `${frameworkLabel(framework)} · build ${buildId}`);
553
+ if (sessions.length === 0) {
554
+ runtime.output.emptyState('No sessions found', 'This build does not currently expose any matching sessions.', 'Recommended next step: confirm the build ID or inspect build details first.');
555
+ }
556
+ else {
557
+ runtime.output.emit(sessions, () => runtime.output.tableFromSessions(sessions));
558
+ }
559
+ await pause('Press Enter to return');
560
+ return;
561
+ }
562
+ const sessionId = await input({ message: 'Session ID' });
563
+ const buildId = framework === 'appium' || framework === 'detox-android'
564
+ ? undefined
565
+ : await input({ message: 'Build ID' });
566
+ const response = await runtime.runWithSpinner('Loading session details…', async () => service.execute({
567
+ framework,
568
+ resource: 'sessions',
569
+ operation: 'get',
570
+ pathParams: framework === 'appium' || framework === 'detox-android'
571
+ ? { sessionID: sessionId }
572
+ : { buildId, sessionId },
573
+ }));
574
+ await runtime.saveLastResponse({
575
+ command: 'interactive sessions get',
576
+ framework,
577
+ resource: 'sessions',
578
+ operation: 'get',
579
+ payload: response,
580
+ });
581
+ runtime.output.clear();
582
+ runtime.output.title('Session details', `${frameworkLabel(framework)} · ${sessionId}`);
583
+ runtime.output.emit(response);
584
+ await pause('Press Enter to return');
585
+ }
586
+ async function runMediaScreen(runtime) {
587
+ const choice = await renderActionScreen(runtime, 'Media', 'Manage reusable media files for test runs.', [
588
+ 'Use media uploads for scenarios that require files to be available inside the app during test execution.',
589
+ 'Typical output: media_url and related identifiers.',
590
+ 'Auth required: yes.',
591
+ ], [
592
+ { name: 'Upload media', value: 'upload', description: 'Upload a local file and receive a reusable media_url.' },
593
+ { name: 'Browse media', value: 'list', description: 'List recent uploaded media files.' },
594
+ { name: 'Delete media', value: 'delete', description: 'Remove a media asset you no longer need.' },
595
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
596
+ ]);
597
+ if (choice === 'back') {
598
+ return;
599
+ }
600
+ const service = await runtime.getResourceService();
601
+ if (choice === 'upload') {
602
+ runtime.output.clear();
603
+ runtime.output.title('Upload media', 'Upload a shared file for supported App Automate scenarios.');
604
+ runtime.output.lines([
605
+ 'Supports: browserstack media workflows and framework payloads that accept media references.',
606
+ 'Tip: keep the returned media_url handy for later build execution payloads.',
607
+ ]);
608
+ const filePath = await input({ message: 'File path' });
609
+ const customId = await input({ message: 'Custom ID (optional)', default: '' });
610
+ const response = await runtime.runWithSpinner('Uploading media…', async () => service.execute({
611
+ framework: 'media',
612
+ resource: 'media',
613
+ operation: 'upload',
614
+ filePath,
615
+ fields: { custom_id: customId || undefined },
616
+ }));
617
+ await runtime.saveLastResponse({
618
+ command: 'interactive media upload',
619
+ framework: 'media',
620
+ resource: 'media',
621
+ operation: 'upload',
622
+ payload: response,
623
+ });
624
+ runtime.output.clear();
625
+ runtime.output.title('Media upload complete', 'Use the returned media_url in supported test flows.');
626
+ runtime.output.emit(response);
627
+ await pause('Press Enter to return');
628
+ return;
629
+ }
630
+ if (choice === 'list') {
631
+ const scope = await select({
632
+ message: 'Scope',
633
+ choices: [
634
+ { name: 'User', value: 'user', description: 'Only media uploaded by the current account.' },
635
+ { name: 'Group', value: 'group', description: 'Media shared with the BrowserStack group.' },
636
+ ],
637
+ });
638
+ const operation = scope === 'group' ? 'list-group' : 'list';
639
+ const response = await runtime.runWithSpinner('Loading media…', async () => service.execute({
640
+ framework: 'media',
641
+ resource: 'media',
642
+ operation,
643
+ query: { limit: 20 },
644
+ }));
645
+ await runtime.saveLastResponse({
646
+ command: 'interactive media list',
647
+ framework: 'media',
648
+ resource: 'media',
649
+ operation,
650
+ payload: response,
651
+ });
652
+ runtime.output.clear();
653
+ runtime.output.title('Media', `${scope === 'group' ? 'Group' : 'User'} uploads`);
654
+ runtime.output.emit(response);
655
+ await pause('Press Enter to return');
656
+ return;
657
+ }
658
+ const mediaId = await input({ message: 'Media ID' });
659
+ const accepted = await confirm({
660
+ message: `Delete media ${mediaId}?`,
661
+ default: false,
662
+ });
663
+ if (!accepted) {
664
+ runtime.output.warning('Delete cancelled.');
665
+ await pause('Press Enter to return');
666
+ return;
667
+ }
668
+ const response = await runtime.runWithSpinner('Deleting media…', async () => service.execute({
669
+ framework: 'media',
670
+ resource: 'media',
671
+ operation: 'delete',
672
+ pathParams: { media_id: mediaId },
673
+ }));
674
+ runtime.output.clear();
675
+ runtime.output.title('Media removed', mediaId);
676
+ runtime.output.emit(response);
677
+ await pause('Press Enter to return');
678
+ }
679
+ async function runFrameworksScreen(runtime, uiState) {
680
+ runtime.output.clear();
681
+ runtime.output.title('Frameworks', 'Switch your current focus and see framework-specific guidance.');
682
+ runtime.output.lines([
683
+ `Current focus: ${frameworkLabel(uiState.activeFramework)}`,
684
+ '',
685
+ ...frameworkDescriptors.map((descriptor) => `- ${descriptor.label}: ${descriptor.shortDescription}`),
686
+ ]);
687
+ runtime.output.divider();
688
+ const framework = await select({
689
+ message: 'Choose a framework focus',
690
+ choices: [
691
+ ...sortFrameworks(uiState.activeFramework).map((descriptor) => ({
692
+ name: descriptor.label,
693
+ value: descriptor.key,
694
+ description: descriptor.shortDescription,
695
+ })),
696
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
697
+ ],
698
+ });
699
+ if (framework === 'back') {
700
+ return;
701
+ }
702
+ uiState.activeFramework = framework;
703
+ await runtime.setCurrentFramework(framework);
704
+ runtime.output.success(`Active framework set to ${frameworkLabel(framework)}.`);
705
+ await pause('Press Enter to return');
706
+ }
707
+ async function runToolsScreen(runtime, uiState) {
708
+ const choice = await renderActionScreen(runtime, 'Tools', 'Advanced workflows and power-user utilities.', [
709
+ 'Use this area for search, export, raw endpoint access, and health refresh.',
710
+ 'Auth required: usually yes.',
711
+ 'These actions are useful, but they are intentionally secondary to the task-first main navigation.',
712
+ ], [
713
+ {
714
+ name: 'Search resources',
715
+ value: 'search',
716
+ description: 'Search recent uploads by framework, resource type, and text term.',
717
+ },
718
+ {
719
+ name: 'Raw endpoint explorer',
720
+ value: 'explorer',
721
+ description: 'Choose a framework/resource/operation and execute it directly.',
722
+ },
723
+ {
724
+ name: 'Export last response',
725
+ value: 'export',
726
+ description: 'Write the last captured response to a file for reuse or debugging.',
727
+ },
728
+ {
729
+ name: 'Refresh account status',
730
+ value: 'refresh',
731
+ description: 'Re-check BrowserStack connection health and update dashboard state.',
732
+ },
733
+ {
734
+ name: 'Show last response summary',
735
+ value: 'last-response',
736
+ description: 'Inspect the most recent captured response without leaving interactive mode.',
737
+ },
738
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
739
+ ]);
740
+ if (choice === 'back') {
741
+ return;
742
+ }
743
+ if (choice === 'search') {
744
+ await runSearchMenu(runtime, uiState.activeFramework);
745
+ return;
746
+ }
747
+ if (choice === 'explorer') {
748
+ const { createExplorerCommand } = await import('../commands/explorer.js');
749
+ await createExplorerCommand(runtime).parseAsync(['explorer'], { from: 'user' });
750
+ await pause('Press Enter to return');
751
+ return;
752
+ }
753
+ if (choice === 'export') {
754
+ const filePath = await input({ message: 'Export path' });
755
+ const force = await confirm({ message: 'Overwrite if the file already exists?', default: false });
756
+ await runtime.exportLastResponse(filePath, force);
757
+ runtime.output.success(`Exported last response to ${filePath}`);
758
+ await pause('Press Enter to return');
759
+ return;
760
+ }
761
+ if (choice === 'refresh') {
762
+ await runtime.runWithSpinner('Checking BrowserStack connection…', async () => {
763
+ const { session } = await runtime.auth.getActiveSession(runtime.options.masterKey);
764
+ if (!session) {
765
+ throw new Error('No connected account is available to validate.');
766
+ }
767
+ await runtime.auth.validate(session);
768
+ });
769
+ runtime.output.success('Account status refreshed.');
770
+ await pause('Press Enter to return');
771
+ return;
772
+ }
773
+ const last = await runtime.getLastResponse();
774
+ runtime.output.clear();
775
+ runtime.output.title('Last response', 'Most recent captured API result.');
776
+ if (!last) {
777
+ runtime.output.emptyState('No saved response yet', 'Run an action such as upload, list, build, or session inspection first.');
778
+ }
779
+ else {
780
+ runtime.output.lines([
781
+ `Command: ${last.command ?? 'N/A'}`,
782
+ `Captured: ${formatTime(last.at)}`,
783
+ `Scope: ${[last.framework, last.resource, last.operation].filter(Boolean).join(' / ') || 'N/A'}`,
784
+ '',
785
+ prettyJson(last.payload),
786
+ ]);
787
+ }
788
+ await pause('Press Enter to return');
789
+ }
790
+ async function runSettingsScreen(runtime, uiState) {
791
+ const snapshot = await runtime.getDashboardSnapshot();
792
+ const status = snapshot.sessionStatus;
793
+ const choice = await renderActionScreen(runtime, 'Settings', 'Manage credentials, account state, active framework, and recovery actions.', [
794
+ `Connected account: ${status.username ?? 'None'}`,
795
+ `Auth source: ${humanAuthSource(status.authSource)}`,
796
+ `Current framework focus: ${frameworkLabel(uiState.activeFramework)}`,
797
+ 'Auth is intentionally kept here instead of dominating the main dashboard once you are signed in.',
798
+ ], [
799
+ { name: 'Account status', value: 'status', description: 'See auth source, validation state, and saved account context.' },
800
+ { name: 'Reconnect account', value: 'reconnect', description: 'Validate the current credentials again and refresh API health.' },
801
+ { name: 'Switch account', value: 'switch', description: 'Log in with a different BrowserStack account.' },
802
+ { name: 'Logout', value: 'logout', description: 'Clear saved session credentials from local storage.' },
803
+ { name: 'Choose active framework', value: 'framework', description: 'Set the framework focus used by task-first workflows.' },
804
+ { name: 'Back', value: 'back', description: 'Return to the dashboard.' },
805
+ ]);
806
+ if (choice === 'back') {
807
+ return;
808
+ }
809
+ if (choice === 'status') {
810
+ runtime.output.clear();
811
+ runtime.output.title('Account status', 'Current authentication and persistence details.');
812
+ runtime.output.lines([
813
+ `Connected as: ${status.username ?? 'Not connected'}`,
814
+ `Connection state: ${humanStateLabel(status.connectionState)}`,
815
+ `Auth source: ${humanAuthSource(status.authSource)}`,
816
+ `Saved at: ${status.savedAt ? formatTime(status.savedAt) : 'Not saved'}`,
817
+ `Last successful API check: ${status.lastValidatedAt ? formatTime(status.lastValidatedAt) : 'Not checked yet'}`,
818
+ `Last validation error: ${status.lastValidationError ?? 'None'}`,
819
+ ]);
820
+ await pause('Press Enter to return');
821
+ return;
822
+ }
823
+ if (choice === 'reconnect') {
824
+ const { session } = await runtime.auth.getActiveSession(runtime.options.masterKey);
825
+ if (!session) {
826
+ runtime.output.warning('No credentials are available to reconnect.');
827
+ await pause('Press Enter to return');
828
+ return;
829
+ }
830
+ await runtime.runWithSpinner('Checking BrowserStack connection…', async () => {
831
+ await runtime.auth.validate(session);
832
+ });
833
+ runtime.output.success('Connection refreshed.');
834
+ await pause('Press Enter to return');
835
+ return;
836
+ }
837
+ if (choice === 'switch') {
838
+ const prompted = await promptForLogin();
839
+ await runtime.runWithSpinner('Switching account…', async () => {
840
+ await runtime.auth.login(prompted);
841
+ });
842
+ await runOnboarding(runtime);
843
+ return;
844
+ }
845
+ if (choice === 'logout') {
846
+ if (status.authSource === 'environment') {
847
+ runtime.output.warning('This session is currently coming from environment variables. Logging out only clears saved local credentials; environment variables will still reconnect on the next launch.');
848
+ }
849
+ const accepted = await confirm({
850
+ message: 'Clear saved credentials?',
851
+ default: false,
852
+ });
853
+ if (!accepted) {
854
+ return;
855
+ }
856
+ await runtime.auth.logout();
857
+ runtime.output.success('Saved session removed.');
858
+ await pause('Press Enter to continue');
859
+ return;
860
+ }
861
+ await runFrameworksScreen(runtime, uiState);
862
+ }
863
+ async function runHelpScreen(runtime) {
864
+ runtime.output.clear();
865
+ runtime.output.title('Help', 'What this tool does, how interactive mode works, and how to move faster.');
866
+ runtime.output.lines([
867
+ 'What this tool is:',
868
+ '- A BrowserStack App Automate workspace for uploads, builds, sessions, media, and account-aware terminal workflows.',
869
+ '',
870
+ 'Supported frameworks:',
871
+ ...frameworkDescriptors.map((descriptor) => `- ${descriptor.label}: ${descriptor.shortDescription}`),
872
+ '',
873
+ 'Common workflows:',
874
+ '1. Connect your BrowserStack account',
875
+ '2. Upload an app or test artifact',
876
+ '3. Open builds to see run status',
877
+ '4. Open sessions to inspect execution details',
878
+ '5. Use Media for reusable files in supported test flows',
879
+ '',
880
+ 'Interactive mode tips:',
881
+ '- Dashboard is the home screen once connected.',
882
+ '- Settings handles account management and reconnect flows.',
883
+ '- Tools contains advanced actions like raw endpoint exploration and export.',
884
+ '',
885
+ 'Keyboard model:',
886
+ '- Arrow keys move through prompts',
887
+ '- Enter opens the selected action',
888
+ '- Escape cancels the current prompt when supported by your terminal',
889
+ '',
890
+ 'Credential storage:',
891
+ '- Environment variables if provided',
892
+ '- Otherwise OS keychain, encrypted file, or explicit plain-file fallback',
893
+ '- Stored credentials live outside the repository root',
894
+ '',
895
+ 'Switching to command mode:',
896
+ '- Use `bstack --help` for non-interactive commands',
897
+ '- Useful for CI, shell history, and repeatable automation',
898
+ '',
899
+ 'Debugging:',
900
+ '- Start the CLI with `--debug-http` to see request flow',
901
+ '- Use Tools -> Raw endpoint explorer for low-level investigation',
902
+ ]);
903
+ await pause('Press Enter to return');
904
+ }
905
+ async function runSearchMenu(runtime, preferredFramework) {
906
+ const framework = await chooseFrameworkForResource(runtime, 'apps', preferredFramework, {
907
+ includeMedia: true,
908
+ title: 'Search resources',
909
+ });
910
+ const resource = await select({
911
+ message: 'Resource type',
912
+ choices: [
913
+ { name: 'Apps', value: 'apps', description: 'Uploaded application binaries.' },
914
+ { name: 'Test suites', value: 'test-suites', description: 'Uploaded test suite artifacts.' },
915
+ { name: 'Test packages', value: 'test-packages', description: 'Flutter iOS package artifacts.' },
916
+ { name: 'Media', value: 'media', description: 'Uploaded media assets.' },
917
+ ],
918
+ });
919
+ const term = await input({ message: 'Search term (custom_id, name, or URL fragment)' });
920
+ const service = await runtime.getResourceService();
921
+ const response = await runtime.runWithSpinner('Searching recent resources…', async () => service.execute({
922
+ framework,
923
+ resource,
924
+ operation: 'list',
925
+ }));
926
+ const text = JSON.stringify(response).toLowerCase();
927
+ runtime.output.clear();
928
+ runtime.output.title('Search results', `${frameworkLabel(framework)} · ${resource}`);
929
+ runtime.output.emit({
930
+ term,
931
+ matched: text.includes(term.toLowerCase()),
932
+ payload: response,
933
+ });
934
+ await pause('Press Enter to return');
935
+ }
936
+ async function runDeleteWorkflow(runtime, uiState) {
937
+ const resource = await select({
938
+ message: 'What do you want to delete?',
939
+ choices: [
940
+ { name: 'App', value: 'apps', description: 'Delete an uploaded app binary.' },
941
+ { name: 'Test suite', value: 'test-suites', description: 'Delete an uploaded test suite.' },
942
+ { name: 'Flutter iOS package', value: 'test-packages', description: 'Delete a Flutter iOS package.' },
943
+ { name: 'Media file', value: 'media', description: 'Delete an uploaded media asset.' },
944
+ ],
945
+ });
946
+ const framework = resource === 'media'
947
+ ? 'media'
948
+ : await chooseFrameworkForResource(runtime, resource, uiState.activeFramework);
949
+ if (framework !== 'media') {
950
+ uiState.activeFramework = framework;
951
+ await runtime.setCurrentFramework(framework);
952
+ }
953
+ const itemId = await input({ message: 'ID to delete' });
954
+ const accepted = await confirm({
955
+ message: `Delete ${resource} ${itemId}?`,
956
+ default: false,
957
+ });
958
+ if (!accepted) {
959
+ runtime.output.warning('Delete cancelled.');
960
+ await pause('Press Enter to return');
961
+ return;
962
+ }
963
+ const service = await runtime.getResourceService();
964
+ const response = await runtime.runWithSpinner('Deleting item…', async () => service.execute({
965
+ framework,
966
+ resource,
967
+ operation: 'delete',
968
+ pathParams: resource === 'apps'
969
+ ? { appId: itemId, appID: itemId }
970
+ : resource === 'test-suites'
971
+ ? { testSuiteId: itemId }
972
+ : resource === 'test-packages'
973
+ ? { testPackageId: itemId }
974
+ : { media_id: itemId },
975
+ }));
976
+ runtime.output.clear();
977
+ runtime.output.title('Delete complete', `${resource} · ${itemId}`);
978
+ runtime.output.emit(response);
979
+ await pause('Press Enter to return');
980
+ }
981
+ async function runOnboarding(runtime) {
982
+ runtime.output.clear();
983
+ runtime.output.title('Welcome aboard', 'Your BrowserStack account is connected.');
984
+ runtime.output.lines([
985
+ 'Before you start, choose the framework you work with most often. The dashboard and task-first flows will use it as the default focus.',
986
+ ]);
987
+ const framework = await select({
988
+ message: 'Preferred framework',
989
+ choices: frameworkDescriptors.map((descriptor) => ({
990
+ name: descriptor.label,
991
+ value: descriptor.key,
992
+ description: descriptor.shortDescription,
993
+ })),
994
+ });
995
+ await runtime.setCurrentFramework(framework);
996
+ runtime.output.clear();
997
+ runtime.output.title('You are ready to go', `${frameworkLabel(framework)} is now your default focus.`);
998
+ runtime.output.lines([
999
+ 'Three core actions to start with:',
1000
+ '1. Upload an app or test artifact',
1001
+ '2. Browse recent builds to confirm run status',
1002
+ '3. Open sessions for debugging and execution details',
1003
+ ]);
1004
+ await pause('Press Enter to open the dashboard');
1005
+ }
1006
+ async function renderActionScreen(runtime, title, subtitle, context, choices) {
1007
+ runtime.output.clear();
1008
+ runtime.output.title(title, subtitle);
1009
+ runtime.output.section('Context');
1010
+ runtime.output.lines(context);
1011
+ runtime.output.divider();
1012
+ runtime.output.footerHints([
1013
+ '[↑↓] Navigate',
1014
+ '[Enter] Select',
1015
+ 'Descriptions explain each action',
1016
+ 'Back returns to the dashboard',
1017
+ ]);
1018
+ return select({
1019
+ message: title,
1020
+ choices,
1021
+ });
1022
+ }
1023
+ async function chooseFrameworkForResource(runtime, resource, preferred, options) {
1024
+ const service = await runtime.getResourceService();
1025
+ const registry = service.getRegistry();
1026
+ const supported = frameworkDescriptors.filter((descriptor) => {
1027
+ if (!options?.includeMedia && descriptor.key === 'media') {
1028
+ return false;
1029
+ }
1030
+ return registry
1031
+ .listByFramework(descriptor.key)
1032
+ .some((definition) => definition.resource === resource);
1033
+ });
1034
+ const preferredOrdered = supported.sort((left, right) => left.key === preferred ? -1 : right.key === preferred ? 1 : 0);
1035
+ return select({
1036
+ message: options?.title ?? 'Framework',
1037
+ choices: preferredOrdered.map((descriptor) => ({
1038
+ name: descriptor.label,
1039
+ value: descriptor.key,
1040
+ description: descriptor.shortDescription,
1041
+ })),
1042
+ });
1043
+ }
1044
+ async function pause(message) {
1045
+ await input({ message, default: '' });
1046
+ }
1047
+ function humanAuthSource(source) {
1048
+ switch (source) {
1049
+ case 'environment':
1050
+ return 'Environment variables';
1051
+ case 'keychain':
1052
+ return 'OS keychain';
1053
+ case 'encrypted-file':
1054
+ return 'Encrypted config';
1055
+ case 'plain-file':
1056
+ return 'Plain config file';
1057
+ default:
1058
+ return 'Not connected';
1059
+ }
1060
+ }
1061
+ function humanStateLabel(state) {
1062
+ switch (state) {
1063
+ case 'connected':
1064
+ return 'Connected';
1065
+ case 'saved-unvalidated':
1066
+ return 'Saved';
1067
+ case 'invalid':
1068
+ return 'Needs attention';
1069
+ default:
1070
+ return 'Disconnected';
1071
+ }
1072
+ }
1073
+ function statusSentence(status) {
1074
+ switch (status.connectionState) {
1075
+ case 'connected':
1076
+ return 'Your BrowserStack account is ready for uploads, builds, sessions, and media tasks.';
1077
+ case 'saved-unvalidated':
1078
+ return 'Credentials are available. Refresh the connection check if you want a live API validation.';
1079
+ case 'invalid':
1080
+ return 'The current credentials were rejected or could not be validated. Recovery actions are available in Settings.';
1081
+ default:
1082
+ return 'Connect your BrowserStack account to start using uploads, builds, sessions, and media workflows.';
1083
+ }
1084
+ }
1085
+ function frameworkLabel(framework) {
1086
+ return getFrameworkDescriptor(framework)?.label ?? framework;
1087
+ }
1088
+ function formatTime(value) {
1089
+ if (!value) {
1090
+ return 'Unknown';
1091
+ }
1092
+ const date = new Date(value);
1093
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
1094
+ }
1095
+ function sortFrameworks(preferred) {
1096
+ return [...frameworkDescriptors].sort((left, right) => left.key === preferred ? -1 : right.key === preferred ? 1 : 0);
1097
+ }
1098
+ function uploadSupports(framework, resource) {
1099
+ if (resource === 'media') {
1100
+ return 'Media upload';
1101
+ }
1102
+ const descriptor = getFrameworkDescriptor(framework);
1103
+ const rule = descriptor?.uploadRules.find((entry) => entry.resource === resource);
1104
+ return rule?.description ?? frameworkHints[framework];
1105
+ }
1106
+ //# sourceMappingURL=interactiveMenu.js.map