@stacksjs/defaults 0.70.353 → 0.70.355

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 (125) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-dashboard/scripts/audit.ts +0 -1
  4. package/ai/skills/stacks-email/SKILL.md +35 -15
  5. package/app/Actions/Dashboard/Auth/MeAction.ts +8 -7
  6. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  7. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  8. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  9. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  10. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -0
  11. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +3 -4
  12. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -0
  13. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  14. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  15. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  16. package/app/Actions/Dashboard/Commerce/tax-rate-records.ts +9 -0
  17. package/app/Actions/Dashboard/Content/author-input.ts +1 -1
  18. package/app/Actions/Dashboard/Content/page-input.ts +1 -1
  19. package/app/Actions/Dashboard/DashboardStatsAction.ts +0 -1
  20. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +3 -2
  21. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +1 -0
  22. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +3 -2
  23. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  24. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  25. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  26. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  27. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  28. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  29. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  30. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  31. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  32. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  33. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  34. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +3 -4
  35. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  36. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +8 -15
  37. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  38. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +3 -3
  39. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +5 -5
  40. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -16
  41. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +4 -3
  42. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +5 -5
  43. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -2
  44. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +4 -3
  45. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +11 -9
  46. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +10 -12
  47. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +19 -43
  48. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +44 -0
  49. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +5 -5
  50. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +6 -6
  51. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -4
  52. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +7 -6
  53. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +5 -5
  54. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +4 -3
  55. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +5 -5
  56. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +4 -3
  57. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +5 -5
  58. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +4 -3
  59. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +4 -4
  60. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +4 -3
  61. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +5 -5
  62. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
  63. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  64. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  65. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  66. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +4 -3
  67. package/app/Actions/Dashboard/Models/ModelShowAction.ts +15 -18
  68. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +8 -8
  69. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -9
  70. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +4 -3
  71. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  72. package/app/Actions/Dashboard/Models/model-write.ts +2 -10
  73. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +3 -4
  74. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +6 -4
  75. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +1 -0
  76. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +4 -4
  77. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +3 -2
  78. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +4 -4
  79. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +4 -4
  80. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +4 -3
  81. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +3 -2
  82. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +4 -5
  83. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +4 -4
  84. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -0
  85. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +3 -3
  86. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  87. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  88. package/app/Models/CardComment.ts +3 -4
  89. package/app/Models/commerce/TaxRate.ts +48 -0
  90. package/functions/dashboard-api.ts +51 -10
  91. package/functions/inbox.test.ts +2 -0
  92. package/functions/inbox.ts +27 -2
  93. package/functions/public-application-url.test.ts +13 -0
  94. package/functions/public-application-url.ts +27 -0
  95. package/ide/vscode/package.json +1 -1
  96. package/package.json +1 -1
  97. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  98. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  99. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +23 -0
  100. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +4 -0
  101. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +9 -1
  102. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  103. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  104. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  105. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  106. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  107. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  108. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  109. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  110. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  111. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  112. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  113. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  114. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  115. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  116. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  117. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  118. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  119. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  120. package/resources/functions/dashboard/sidebar.ts +4 -4
  121. package/routes/dashboard-api.ts +8 -20
  122. package/views/dashboard/.discovered-models.json +24 -0
  123. package/views/dashboard/layouts/default.stx +19 -16
  124. package/views/dashboard/stores/ci.ts +13 -7
  125. package/views/dashboard/stores/kanban.ts +48 -11
@@ -96,12 +96,27 @@ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts snapsho
96
96
  ```
97
97
  Extracts headings, links (`text -> href`), buttons, forms (action + field count), and ARIA landmarks — useful for auditing structure and catching broken links.
98
98
 
99
+ ### Scenario (stateful SPA interactions)
100
+ ```bash
101
+ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts scenario <url> \
102
+ --step '{"action":"click","selector":"button[data-open]"}' \
103
+ --step '{"action":"fill","selector":"input[name=name]","value":"Example"}' \
104
+ --step '{"action":"click","selector":"button[type=submit]"}' \
105
+ --step '{"action":"assert","selector":"main","text":"Saved"}' \
106
+ --out storage/framework/runtime/shots/scenario.png
107
+ ```
108
+
109
+ Steps run in order within one isolated browser page, preserving reactive STX state and SPA navigation. Supported actions are `click`, `fill`, `press`, `wait`, and `assert`. Each step is a JSON object passed through a repeatable `--step` flag. A click step may include `text` to select the matching control from its CSS selector. The command reports every completed step, the final URL and page text, console exceptions, failed requests, and an optional screenshot. It exits nonzero when the scenario assertion, browser console, or network fails.
110
+
99
111
  ### Crawl (whole-site browser audit)
100
112
  ```bash
101
- bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--max 500] [--path /extra-route] [--settle 350] [--progress]
113
+ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra-route] [--settle 350] [--progress]
102
114
  ```
103
- Reuses one Chromium process with a fresh isolated page target per route while
115
+ Uses a fresh isolated page target per route, relaunching Chromium if its DevTools
116
+ session exits during a long audit, while
104
117
  following every same-origin link it discovers.
118
+ The viewport is deterministic and defaults to `1280x900`; pass `--viewport 768x1024`
119
+ to repeat the same route audit at a tablet breakpoint.
105
120
  Each page is checked for a non-200 document, console exceptions, failed
106
121
  requests, and horizontal overflow. Repeat `--path` to seed routes that are not
107
122
  linked from the starting page. The command prints every crawled path and exits
@@ -12,7 +12,8 @@
12
12
  * bun browse.ts responsive <url> [--out-dir DIR]
13
13
  * bun browse.ts monitor <url> [--ms 5000]
14
14
  * bun browse.ts snapshot <url>
15
- * bun browse.ts crawl <url> [--max 500] [--path /extra] [--progress]
15
+ * bun browse.ts scenario <url> --step '{"action":"click","selector":"button"}'
16
+ * bun browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra] [--progress]
16
17
  *
17
18
  * Browser discovery order: $BROWSE_BROWSER → PATH (chromium, google-chrome, …)
18
19
  * → common macOS app bundles → a Playwright-cached chromium as last resort.
@@ -405,6 +406,14 @@ interface CrawlPage {
405
406
  consoleErrors: string[]
406
407
  failedRequests: Array<{ status: number, url: string }>
407
408
  horizontalOverflowPx: number
409
+ layout: {
410
+ bodyScrollWidth: number
411
+ documentScrollWidth: number
412
+ mainClientWidth: number | null
413
+ mainOverflowX: string | null
414
+ mainScrollWidth: number | null
415
+ viewportWidth: number
416
+ }
408
417
  overflowingElements: Array<{
409
418
  element: string
410
419
  left: number
@@ -488,6 +497,118 @@ function parseViewport(value: string | undefined): { w: number, h: number } {
488
497
  return { w, h }
489
498
  }
490
499
 
500
+ type ScenarioAction = 'assert' | 'click' | 'fill' | 'press' | 'wait'
501
+
502
+ interface ScenarioStep {
503
+ action: ScenarioAction
504
+ selector?: string
505
+ text?: string
506
+ value?: string
507
+ key?: string
508
+ ms?: number
509
+ settle?: number
510
+ }
511
+
512
+ function parseScenarioStep(value: string): ScenarioStep {
513
+ let parsed: unknown
514
+ try {
515
+ parsed = JSON.parse(value)
516
+ }
517
+ catch (error) {
518
+ throw new TypeError(`Invalid scenario step JSON: ${value}`, { cause: error })
519
+ }
520
+
521
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
522
+ throw new TypeError('Each scenario step must be a JSON object.')
523
+
524
+ const step = parsed as Record<string, unknown>
525
+ if (!['assert', 'click', 'fill', 'press', 'wait'].includes(String(step.action)))
526
+ throw new TypeError(`Unsupported scenario action: ${String(step.action)}`)
527
+
528
+ const action = step.action as ScenarioAction
529
+ if (['assert', 'click', 'fill'].includes(action) && typeof step.selector !== 'string')
530
+ throw new TypeError(`Scenario action "${action}" requires a selector.`)
531
+ if (action === 'fill' && typeof step.value !== 'string')
532
+ throw new TypeError('Scenario action "fill" requires a string value.')
533
+ if (action === 'press' && typeof step.key !== 'string')
534
+ throw new TypeError('Scenario action "press" requires a key.')
535
+ if (action === 'wait' && (!Number.isFinite(step.ms) || Number(step.ms) < 0 || Number(step.ms) > 30_000))
536
+ throw new TypeError('Scenario action "wait" requires ms between 0 and 30000.')
537
+
538
+ return step as unknown as ScenarioStep
539
+ }
540
+
541
+ async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<string, unknown>> {
542
+ if (step.action === 'wait') {
543
+ await Bun.sleep(Number(step.ms))
544
+ return { action: step.action, ms: Number(step.ms), ok: true }
545
+ }
546
+
547
+ if (step.action === 'press') {
548
+ const key = step.key || ''
549
+ const keyCode = key.length === 1 ? key.charCodeAt(0) : ({ Enter: 13, Escape: 27, Tab: 9 }[key] || 0)
550
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code: key, windowsVirtualKeyCode: keyCode })
551
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code: key, windowsVirtualKeyCode: keyCode })
552
+ return { action: step.action, key, ok: true }
553
+ }
554
+
555
+ const result = await cdp.send('Runtime.evaluate', {
556
+ expression: `(() => {
557
+ const step = ${JSON.stringify(step)}
558
+ const candidates = Array.from(document.querySelectorAll(step.selector))
559
+ const normalizedText = (element) => (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
560
+ const element = step.action === 'click' && step.text !== undefined
561
+ ? candidates.find(candidate => normalizedText(candidate) === step.text)
562
+ || candidates.find(candidate => normalizedText(candidate).includes(step.text))
563
+ : candidates[0]
564
+ if (!element)
565
+ return { ok: false, error: 'Element not found', selector: step.selector }
566
+
567
+ const rect = element.getBoundingClientRect()
568
+ const style = getComputedStyle(element)
569
+ const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
570
+ if (!visible)
571
+ return { ok: false, error: 'Element is not visible', selector: step.selector }
572
+
573
+ if (step.action === 'click') {
574
+ if (element.disabled || element.getAttribute('aria-disabled') === 'true')
575
+ return { ok: false, error: 'Element is disabled', selector: step.selector }
576
+ element.click()
577
+ }
578
+ else if (step.action === 'fill') {
579
+ if (!('value' in element))
580
+ return { ok: false, error: 'Element has no value', selector: step.selector }
581
+ const prototype = Object.getPrototypeOf(element)
582
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
583
+ if (setter)
584
+ setter.call(element, step.value)
585
+ else
586
+ element.value = step.value
587
+ element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: step.value }))
588
+ element.dispatchEvent(new Event('change', { bubbles: true }))
589
+ }
590
+ else if (step.action === 'assert' && step.text !== undefined) {
591
+ const content = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
592
+ if (!content.includes(step.text))
593
+ return { ok: false, error: 'Expected text was not found', selector: step.selector, expected: step.text, actual: content.slice(0, 240) }
594
+ }
595
+
596
+ return {
597
+ ok: true,
598
+ action: step.action,
599
+ selector: step.selector,
600
+ tag: element.tagName.toLowerCase(),
601
+ text: normalizedText(element).slice(0, 160),
602
+ }
603
+ })()`,
604
+ returnByValue: true,
605
+ })
606
+ const value = result.result?.value as Record<string, unknown> | undefined
607
+ if (!value?.ok)
608
+ throw new Error(`Scenario ${step.action} failed: ${String(value?.error || 'unknown error')} (${step.selector || step.key || ''})`)
609
+ return value
610
+ }
611
+
491
612
  const BREAKPOINTS = [
492
613
  { device: 'Mobile S', w: 320, h: 568 },
493
614
  { device: 'Mobile L', w: 428, h: 926 },
@@ -502,14 +623,14 @@ async function main() {
502
623
  const url = positional[0]
503
624
 
504
625
  if (!command || command === 'help' || !url) {
505
- console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|crawl> <url> [flags]')
626
+ console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|scenario|crawl> <url> [flags]')
506
627
  console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
507
628
  console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
508
629
  console.log(' --scheme dark emulate prefers-color-scheme (light|dark) for QA of theme-aware pages')
509
630
  process.exit(url ? 0 : 1)
510
631
  }
511
632
 
512
- const session = await launch()
633
+ let session = await launch()
513
634
  const cookies = flagList(flags.cookie)
514
635
  const settleMs = flags.settle ? Number(flags.settle) : undefined
515
636
  const scheme = typeof flags.scheme === 'string' ? flags.scheme : undefined
@@ -554,7 +675,7 @@ async function main() {
554
675
  const cdp = await openPage(session.port)
555
676
  const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme })
556
677
  const overflow = await cdp.send('Runtime.evaluate', {
557
- expression: 'document.documentElement.scrollWidth > window.innerWidth ? document.documentElement.scrollWidth - window.innerWidth : 0',
678
+ expression: 'document.body.scrollWidth > window.innerWidth ? document.body.scrollWidth - window.innerWidth : 0',
558
679
  returnByValue: true,
559
680
  })
560
681
  const out = join(outDir, `${bp.device.toLowerCase().replace(/\s+/g, '-')}.png`)
@@ -611,9 +732,57 @@ async function main() {
611
732
  cdp.close()
612
733
  }
613
734
 
735
+ else if (command === 'scenario') {
736
+ const steps = flagList(flags.step).map(parseScenarioStep)
737
+ if (!steps.length)
738
+ throw new TypeError('Scenario requires at least one --step JSON object.')
739
+
740
+ const cdp = await openPage(session.port)
741
+ const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
742
+ const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
743
+ const results: Record<string, unknown>[] = []
744
+
745
+ for (const step of steps) {
746
+ results.push(await runScenarioStep(cdp, step))
747
+ if (step.action !== 'wait')
748
+ await Bun.sleep(step.settle ?? 250)
749
+ }
750
+
751
+ const finalState = await cdp.send('Runtime.evaluate', {
752
+ expression: `({
753
+ url: location.href,
754
+ title: document.title,
755
+ activeElement: document.activeElement?.tagName?.toLowerCase() || null,
756
+ bodyText: (document.body.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 500),
757
+ })`,
758
+ returnByValue: true,
759
+ })
760
+ let screenshot: string | null = null
761
+ if (typeof flags.out === 'string') {
762
+ screenshot = flags.out
763
+ mkdirSync(screenshot.split('/').slice(0, -1).join('/') || '.', { recursive: true })
764
+ await Bun.write(screenshot, await captureScreenshot(cdp, { full: !!flags.full }))
765
+ }
766
+ const failedRequests = state.responses.filter(response => response.status >= 400)
767
+ console.log(JSON.stringify({
768
+ url,
769
+ viewport: `${viewport.w}x${viewport.h}`,
770
+ steps: results,
771
+ final: finalState.result?.value,
772
+ screenshot,
773
+ consoleErrors: state.consoleErrors,
774
+ failedRequests,
775
+ }, null, 2))
776
+ if (state.consoleErrors.length || failedRequests.length)
777
+ process.exitCode = 1
778
+ state.dispose()
779
+ cdp.close()
780
+ }
781
+
614
782
  else if (command === 'crawl') {
615
783
  const start = new URL(url)
616
784
  const max = flags.max ? Number(flags.max) : 500
785
+ const crawlViewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
617
786
  if (!Number.isInteger(max) || max < 1)
618
787
  throw new TypeError(`Invalid crawl max "${String(flags.max)}". Expected a positive integer.`)
619
788
 
@@ -629,9 +798,19 @@ async function main() {
629
798
  const crawlSettleMs = settleMs ?? 350
630
799
  let browserPage = await createPage(session.port)
631
800
  let cdp = browserPage.cdp
801
+ const createReplacementPage = async (): Promise<BrowserPage> => {
802
+ try {
803
+ return await createPage(session.port)
804
+ }
805
+ catch {
806
+ kill(session)
807
+ session = await launch()
808
+ return createPage(session.port)
809
+ }
810
+ }
632
811
  const replacePage = async (): Promise<void> => {
633
812
  await closePage(session.port, browserPage)
634
- browserPage = await createPage(session.port)
813
+ browserPage = await createReplacementPage()
635
814
  cdp = browserPage.cdp
636
815
  }
637
816
  const inspectPage = async (current: string): Promise<{
@@ -640,6 +819,7 @@ async function main() {
640
819
  links?: string[]
641
820
  overflow?: number
642
821
  overflowing?: CrawlPage['overflowingElements']
822
+ layout?: CrawlPage['layout']
643
823
  title?: string
644
824
  }
645
825
  }> => {
@@ -651,12 +831,24 @@ async function main() {
651
831
  cookies,
652
832
  settleMs: crawlSettleMs,
653
833
  scheme,
834
+ viewport: crawlViewport,
654
835
  })
655
836
  const inspection = await cdp.send('Runtime.evaluate', {
656
837
  expression: `(() => ({
657
838
  title: document.title,
658
839
  links: Array.from(document.querySelectorAll('a[href]')).map(link => link.href),
659
- overflow: Math.max(0, document.documentElement.scrollWidth - window.innerWidth),
840
+ overflow: Math.max(0, document.body.scrollWidth - window.innerWidth),
841
+ layout: (() => {
842
+ const main = document.querySelector('[data-stx-content]')
843
+ return {
844
+ bodyScrollWidth: document.body.scrollWidth,
845
+ documentScrollWidth: document.documentElement.scrollWidth,
846
+ mainClientWidth: main?.clientWidth ?? null,
847
+ mainOverflowX: main ? getComputedStyle(main).overflowX : null,
848
+ mainScrollWidth: main?.scrollWidth ?? null,
849
+ viewportWidth: window.innerWidth,
850
+ }
851
+ })(),
660
852
  overflowing: Array.from(document.querySelectorAll('body *'))
661
853
  .map((element) => {
662
854
  const rect = element.getBoundingClientRect()
@@ -736,6 +928,14 @@ async function main() {
736
928
  consoleErrors: [...new Set(state.consoleErrors)],
737
929
  failedRequests,
738
930
  horizontalOverflowPx: Number(value.overflow) || 0,
931
+ layout: value.layout || {
932
+ bodyScrollWidth: 0,
933
+ documentScrollWidth: 0,
934
+ mainClientWidth: null,
935
+ mainOverflowX: null,
936
+ mainScrollWidth: null,
937
+ viewportWidth: 0,
938
+ },
739
939
  overflowingElements: value.overflowing || [],
740
940
  })
741
941
  if (flags.progress) {
@@ -767,6 +967,7 @@ async function main() {
767
967
  console.log(JSON.stringify({
768
968
  start: start.href,
769
969
  browser: session.browser,
970
+ viewport: `${crawlViewport.w}x${crawlViewport.h}`,
770
971
  crawled: pages.length,
771
972
  remaining: queue.length,
772
973
  max,
@@ -227,7 +227,6 @@ function concreteApiPath(
227
227
  dynamicPages: string[],
228
228
  modelRoutes: string[],
229
229
  ): string {
230
- // eslint-disable-next-line pickier/no-unused-vars
231
230
  const match = (prefix: string) => dynamicPages
232
231
  .find(route => route.startsWith(`${prefix}/`))
233
232
  ?.slice(prefix.length + 1)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-email
3
- description: Use when working with email in a Stacks application sending emails via SES/SendGrid/Mailgun/Mailtrap/SMTP, email templates with STX, email drivers, the Mail singleton, the EmailSDK for inbox management, or email configuration. Covers @stacksjs/email, config/email.ts, and app/Mail/.
3
+ description: Use when working with email in a Stacks application - sending emails via SES/SendGrid/Mailgun/Mailtrap/SMTP, email templates with STX, email drivers, the Mail singleton, the EmailSDK for inbox management, inbound MIME parsing, or email configuration. Covers @stacksjs/email, config/email.ts, and app/Mail/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -23,8 +23,9 @@ email/src/
23
23
  ├── index.ts # All exports
24
24
  ├── email.ts # Email class + Mail singleton
25
25
  ├── template.ts # Template rendering engine
26
+ ├── inbound-parser.ts # Bounded RFC MIME parsing for inbound mail
26
27
  ├── types.ts # Types and interfaces
27
- ├── sdk/index.ts # EmailSDK (send + inbox management)
28
+ ├── sdk/index.ts # EmailSDK (send + inbox and attachment management)
28
29
  └── drivers/
29
30
  ├── base.ts # BaseEmailDriver abstract class
30
31
  ├── ses.ts # AWS SES driver
@@ -106,14 +107,16 @@ import { emailSDK, sendEmail, getInbox, searchEmails, deleteEmail } from '@stack
106
107
  await sendEmail({ from: { address: 'a@b.com' }, to: 'c@d.com', subject: 'Hi', html: '<p>Hello</p>' })
107
108
 
108
109
  // Send with template
109
- await emailSDK.sendTemplate({ to: 'user@example.com', templateName: 'welcome', data: { name: 'John' } })
110
+ await emailSDK.sendTemplate({ to: 'user@example.com', template: 'welcome', data: { name: 'John' } })
110
111
 
111
112
  // Read inbox (from S3)
112
113
  const emails = await getInbox('chris', { limit: 20 })
113
114
  const email = await emailSDK.getEmail('chris', messageId)
115
+ const attachments = await emailSDK.getAttachments('chris', messageId)
116
+ const download = await emailSDK.getAttachment('chris', messageId, attachments?.[0]?.id || '')
114
117
 
115
118
  // Search
116
- const results = await searchEmails('chris', { from: 'boss', after: '2024-01-01', hasAttachments: true })
119
+ const results = await searchEmails('chris', { from: 'boss', after: new Date('2024-01-01'), hasAttachments: true })
117
120
 
118
121
  // Manage
119
122
  await emailSDK.markAsRead('chris', messageId)
@@ -237,20 +240,35 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
237
240
  }
238
241
  ```
239
242
 
243
+ ## Inbound MIME and attachment storage
244
+
245
+ `parseInboundEmail()` parses RFC messages with bounded header, nesting, total-size, and attachment-count limits. It returns normalized sender and recipient data, text and HTML bodies, and binary-safe attachments. Attachment filenames are sanitized before they become S3 keys.
246
+
247
+ `buddy email:reprocess` reads raw messages with `getObjectBytes()`, parses them through this shared helper, and writes:
248
+
249
+ - `raw.eml`
250
+ - `metadata.json`
251
+ - `body.txt` and `body.html` when present
252
+ - binary objects under `attachments/`
253
+ - the per-mailbox `inbox.json` index
254
+
255
+ Reprocessing refreshes existing messages instead of skipping them, preserves their read state, and repairs body and attachment metadata written by older versions. The dashboard receives opaque attachment IDs, resolves them against the stored message before download, and never accepts arbitrary S3 keys from a client.
256
+
240
257
  ## CLI Commands
241
- - `buddy email` / `buddy mail` email management
242
- - `buddy email:verify` check domain verification
243
- - `buddy email:test [recipient]` send test email
244
- - `buddy email:list` list mailboxes
245
- - `buddy email:logs -n 50` view logs
246
- - `buddy email:status` server status
247
- - `buddy email:inbox [mailbox]` view inbox from S3
248
- - `buddy mail:user:add <email>` add mail user
249
- - `buddy mail:user:list` list mail users
250
- - `buddy mail:user:delete <email>` delete mail user
258
+ - `buddy email` / `buddy mail` - email management
259
+ - `buddy email:verify` - check domain verification
260
+ - `buddy email:test [recipient]` - send test email
261
+ - `buddy email:list` - list mailboxes
262
+ - `buddy email:logs -n 50` - view logs
263
+ - `buddy email:status` - server status
264
+ - `buddy email:inbox [mailbox]` - view inbox from S3
265
+ - `buddy email:reprocess` - parse raw S3 mail into mailbox bodies and attachments
266
+ - `buddy mail:user:add <email>` - add mail user
267
+ - `buddy mail:user:list` - list mail users
268
+ - `buddy mail:user:delete <email>` - delete mail user
251
269
 
252
270
  ## Gotchas
253
- - Default driver is `ses` requires AWS credentials
271
+ - Default driver is `ses` - requires AWS credentials
254
272
  - Template rendering supports both `.stx` and `.html` files
255
273
  - Variable interpolation uses `{{ }}` double-brace syntax
256
274
  - The `mail` singleton auto-registers all 5 drivers on initialization
@@ -258,5 +276,7 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
258
276
  - SendGrid/Mailgun retry with exponential backoff on failure
259
277
  - Mailtrap requires `inboxId` for sandbox mode
260
278
  - EmailSDK reads inbox from S3 (bucket configured via env)
279
+ - EmailSDK attachment downloads use binary-safe S3 reads and opaque IDs
280
+ - `buddy email:reprocess` preserves existing read state and exits nonzero on failure
261
281
  - Email categorization auto-sorts incoming mail by domain/substring patterns
262
282
  - The `text` fallback is auto-generated from HTML via `htmlToText()`
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { getUserRoles } from '@stacksjs/auth'
3
4
 
@@ -32,7 +33,7 @@ interface MeResponse {
32
33
  * a 401 in the network tab. Instead:
33
34
  *
34
35
  * - If the request carries a valid bearer token, the framework's auth
35
- * middleware already populated `request.user` we return that user
36
+ * middleware already populated `request.user()` - we return that user
36
37
  * plus their roles.
37
38
  * - If not, we return `{ user: null, roles: [], unauthenticated: true }`.
38
39
  * The composable's "no auth → assume dev" default kicks in client-side,
@@ -48,15 +49,15 @@ export default new Action({
48
49
  description: 'Returns the authenticated user + their RBAC roles for dashboard role gating.',
49
50
  method: 'GET',
50
51
  apiResponse: true,
51
- async handle(request) {
52
- const user = (request as any)?.user ?? (request as any)?._authenticatedUser ?? null
52
+ async handle(request: RequestInstance) {
53
+ const user = await request.user()
53
54
 
54
- if (!user || typeof user !== 'object' || typeof (user as { id?: unknown }).id !== 'number') {
55
+ if (!user || !Number.isInteger(Number(user.id))) {
55
56
  const res: MeResponse = { user: null, roles: [], unauthenticated: true }
56
57
  return res
57
58
  }
58
59
 
59
- const userId = (user as { id: number }).id
60
+ const userId = Number(user.id)
60
61
 
61
62
  let roles: string[] = []
62
63
  try {
@@ -74,8 +75,8 @@ export default new Action({
74
75
  const res: MeResponse = {
75
76
  user: {
76
77
  id: userId,
77
- name: (user as { name?: string | null }).name ?? null,
78
- email: (user as { email?: string | null }).email ?? null,
78
+ name: user.name ?? null,
79
+ email: user.email ?? null,
79
80
  },
80
81
  roles,
81
82
  }
@@ -1,6 +1,8 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { dashboard as dashboardConfig } from '@stacksjs/config'
3
4
  import { fetchRunJobs } from '@stacksjs/github'
5
+ import { response } from '@stacksjs/router'
4
6
 
5
7
  /**
6
8
  * `GET /api/dashboard/ci/repos/:owner/:name/runs/:runId/jobs`
@@ -19,27 +21,26 @@ export default new Action({
19
21
  description: 'Job-level breakdown for a single workflow run (drilldown expand).',
20
22
  method: 'GET',
21
23
  apiResponse: true,
22
- async handle(request) {
24
+ async handle(request: RequestInstance) {
23
25
  const ci = dashboardConfig?.ci
24
26
 
25
27
  if (!ci?.enabled) {
26
28
  return { jobs: [], disabled: true }
27
29
  }
28
30
 
29
- const owner = String((request as any)?.params?.owner ?? (request as any)?.param?.('owner') ?? '').trim()
30
- const repo = String((request as any)?.params?.name ?? (request as any)?.param?.('name') ?? '').trim()
31
- const runIdRaw = (request as any)?.params?.runId ?? (request as any)?.param?.('runId') ?? null
32
- const runId = Number(runIdRaw)
31
+ const owner = request.getParam('owner').trim()
32
+ const repo = request.getParam('name').trim()
33
+ const runId = Number(request.getParam('runId'))
33
34
  if (!owner || !repo) {
34
- return { error: 'Both `owner` and `name` route params are required.', status: 400 }
35
+ return response.json({ message: 'Both owner and name route parameters are required.' }, 400)
35
36
  }
36
37
  if (!Number.isFinite(runId) || runId <= 0) {
37
- return { error: '`runId` must be a positive integer.', status: 400 }
38
+ return response.json({ message: 'The run ID must be a positive integer.' }, 400)
38
39
  }
39
40
 
40
41
  const allowedOrgs = ci.orgs ?? []
41
42
  if (allowedOrgs.length > 0 && !allowedOrgs.includes(owner)) {
42
- return { error: 'Org not in `config.dashboard.ci.orgs`.', status: 403 }
43
+ return response.json({ message: 'This organization is not configured for CI tracking.' }, 403)
43
44
  }
44
45
 
45
46
  try {
@@ -48,13 +49,7 @@ export default new Action({
48
49
  }
49
50
  catch (err) {
50
51
  console.error('[dashboard/ci] RepoRunJobsAction failed:', err)
51
- return {
52
- owner,
53
- repo,
54
- runId,
55
- jobs: [],
56
- error: err instanceof Error ? err.message : 'unknown error',
57
- }
52
+ return response.json({ message: 'Workflow jobs could not be loaded.' }, 502)
58
53
  }
59
54
  },
60
55
  })
@@ -1,6 +1,9 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { dashboard as dashboardConfig } from '@stacksjs/config'
3
4
  import { fetchWorkflowRuns } from '@stacksjs/github'
5
+ import { response } from '@stacksjs/router'
6
+ import { dashboardRequestValue } from '../dashboard-request'
4
7
 
5
8
  /**
6
9
  * `GET /api/dashboard/ci/repos/:owner/:name/runs?limit=N`
@@ -23,32 +26,32 @@ export default new Action({
23
26
  description: 'Latest N workflow runs for a single repo (drilldown).',
24
27
  method: 'GET',
25
28
  apiResponse: true,
26
- async handle(request) {
29
+ async handle(request: RequestInstance) {
27
30
  const ci = dashboardConfig?.ci
28
31
 
29
32
  if (!ci?.enabled) {
30
33
  return { owner: null, repo: null, runs: [], disabled: true }
31
34
  }
32
35
 
33
- const owner = String((request as any)?.params?.owner ?? (request as any)?.param?.('owner') ?? '').trim()
34
- const repo = String((request as any)?.params?.name ?? (request as any)?.param?.('name') ?? '').trim()
36
+ const owner = request.getParam('owner').trim()
37
+ const repo = request.getParam('name').trim()
35
38
  if (!owner || !repo) {
36
- return { error: 'Both `owner` and `name` route params are required.', status: 400 }
39
+ return response.json({ message: 'Both owner and name route parameters are required.' }, 400)
37
40
  }
38
41
 
39
42
  // Scope check: only fetch runs for orgs the user explicitly
40
43
  // configured. Stops the endpoint from being a generic GH proxy.
41
44
  const allowedOrgs = ci.orgs ?? []
42
45
  if (allowedOrgs.length > 0 && !allowedOrgs.includes(owner)) {
43
- return { error: 'Org not in `config.dashboard.ci.orgs`.', status: 403 }
46
+ return response.json({ message: 'This organization is not configured for CI tracking.' }, 403)
44
47
  }
45
48
 
46
- const url = new URL(request.url ?? `http://localhost/`)
47
- const rawLimit = Number(url.searchParams.get('limit') ?? '20')
49
+ const rawLimit = Number(dashboardRequestValue(request, 'limit', '20'))
48
50
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
49
51
  ? Math.min(Math.floor(rawLimit), 100)
50
52
  : 20
51
- const branch = url.searchParams.get('branch') || undefined
53
+ const branchValue = dashboardRequestValue(request, 'branch')
54
+ const branch = branchValue || undefined
52
55
 
53
56
  try {
54
57
  const runs = await fetchWorkflowRuns(owner, repo, { limit, branch })
@@ -56,12 +59,7 @@ export default new Action({
56
59
  }
57
60
  catch (err) {
58
61
  console.error('[dashboard/ci] RepoRunsAction failed:', err)
59
- return {
60
- owner,
61
- repo,
62
- runs: [],
63
- error: err instanceof Error ? err.message : 'unknown error',
64
- }
62
+ return response.json({ message: 'Workflow runs could not be loaded.' }, 502)
65
63
  }
66
64
  },
67
65
  })
@@ -1,5 +1,8 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { dashboard as dashboardConfig } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardRequestValue } from '../dashboard-request'
3
6
  import { fetchRunnerHistory } from './runner-pressure-monitor'
4
7
 
5
8
  /**
@@ -12,33 +15,31 @@ import { fetchRunnerHistory } from './runner-pressure-monitor'
12
15
  * configured retention window — older samples aren't available
13
16
  * because they've been pruned.
14
17
  *
15
- * Soft-errors to an empty samples array on any failure so the
16
- * sparkline gracefully degrades to "no history yet" instead of
17
- * yelling at the user.
18
+ * Operational failures use a real 503 response so the client can
19
+ * distinguish unavailable history from a valid empty sample set.
18
20
  */
19
21
  export default new Action({
20
22
  name: 'Dashboard CI Runner History',
21
23
  description: 'Recent runner-sample history for one org (sparkline data).',
22
24
  method: 'GET',
23
25
  apiResponse: true,
24
- async handle(request) {
26
+ async handle(request: RequestInstance) {
25
27
  const ci = dashboardConfig?.ci
26
28
  if (!ci?.enabled || !ci.alerts?.enabled) {
27
29
  return { org: null, samples: [], disabled: true }
28
30
  }
29
31
 
30
- const url = new URL(request.url ?? 'http://localhost/')
31
- const org = String(url.searchParams.get('org') ?? '').trim()
32
+ const org = dashboardRequestValue(request, 'org')
32
33
  if (!org) {
33
- return { error: '`org` query param is required.', status: 400 }
34
+ return response.json({ message: 'The org query parameter is required.' }, 400)
34
35
  }
35
36
 
36
37
  const allowedOrgs = ci.orgs ?? []
37
38
  if (allowedOrgs.length > 0 && !allowedOrgs.includes(org)) {
38
- return { error: 'Org not in `config.dashboard.ci.orgs`.', status: 403 }
39
+ return response.json({ message: 'This organization is not configured for CI tracking.' }, 403)
39
40
  }
40
41
 
41
- const rawLimit = Number(url.searchParams.get('limit') ?? '60')
42
+ const rawLimit = Number(dashboardRequestValue(request, 'limit', '60'))
42
43
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
43
44
  ? Math.min(Math.floor(rawLimit), 500)
44
45
  : 60
@@ -52,7 +53,7 @@ export default new Action({
52
53
  }
53
54
  catch (err) {
54
55
  console.error('[dashboard/ci] RunnerHistoryAction failed:', err)
55
- return { org, samples: [], error: err instanceof Error ? err.message : 'unknown error' }
56
+ return response.json({ message: 'Runner history could not be loaded.' }, 503)
56
57
  }
57
58
  },
58
59
  })