@stacksjs/defaults 0.70.354 → 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.
- package/ai/skills/stacks-browse/SKILL.md +17 -2
- package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
- package/ai/skills/stacks-email/SKILL.md +35 -15
- package/app/Actions/Dashboard/Auth/MeAction.ts +8 -7
- package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
- package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
- package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
- package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +3 -4
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +3 -2
- package/app/Actions/Dashboard/Deployments/GetDeployment.ts +1 -0
- package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +3 -2
- package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
- package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
- package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
- package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
- package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
- package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
- package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
- package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +3 -4
- package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +8 -15
- package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
- package/app/Actions/Dashboard/Jobs/JobShowAction.ts +3 -3
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -16
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -2
- package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +11 -9
- package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +10 -12
- package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +19 -43
- package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +44 -0
- package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -4
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +7 -6
- package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +4 -4
- package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +4 -3
- package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +5 -5
- package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
- package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
- package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +4 -3
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +15 -18
- package/app/Actions/Dashboard/Models/ModelStoreAction.ts +8 -8
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -9
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +4 -3
- package/app/Actions/Dashboard/Models/model-api.ts +27 -0
- package/app/Actions/Dashboard/Models/model-write.ts +2 -10
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +3 -4
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +6 -4
- package/app/Actions/Dashboard/Queries/QueryShowAction.ts +1 -0
- package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +4 -4
- package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +4 -4
- package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +4 -4
- package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +4 -3
- package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +4 -5
- package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +4 -4
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -0
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +3 -3
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
- package/app/Actions/Dashboard/dashboard-request.ts +13 -0
- package/app/Models/CardComment.ts +3 -4
- package/functions/dashboard-api.ts +51 -10
- package/functions/inbox.test.ts +2 -0
- package/functions/inbox.ts +27 -2
- package/functions/public-application-url.test.ts +13 -0
- package/functions/public-application-url.ts +27 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
- package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
- package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
- package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
- package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
- package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
- package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
- package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
- package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
- package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
- package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
- package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
- package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
- package/resources/functions/dashboard/sidebar.ts +4 -4
- package/routes/dashboard-api.ts +8 -20
- package/views/dashboard/.discovered-models.json +24 -0
- package/views/dashboard/layouts/default.stx +19 -16
- package/views/dashboard/stores/ci.ts +13 -7
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-email
|
|
3
|
-
description: Use when working with email in a Stacks application
|
|
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',
|
|
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`
|
|
242
|
-
- `buddy email:verify`
|
|
243
|
-
- `buddy email:test [recipient]`
|
|
244
|
-
- `buddy email:list`
|
|
245
|
-
- `buddy email:logs -n 50`
|
|
246
|
-
- `buddy email:status`
|
|
247
|
-
- `buddy email:inbox [mailbox]`
|
|
248
|
-
- `buddy
|
|
249
|
-
- `buddy mail:user:
|
|
250
|
-
- `buddy 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`
|
|
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`
|
|
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 =
|
|
52
|
+
async handle(request: RequestInstance) {
|
|
53
|
+
const user = await request.user()
|
|
53
54
|
|
|
54
|
-
if (!user ||
|
|
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
|
|
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:
|
|
78
|
-
email:
|
|
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 =
|
|
30
|
-
const repo =
|
|
31
|
-
const
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
34
|
-
const repo =
|
|
36
|
+
const owner = request.getParam('owner').trim()
|
|
37
|
+
const repo = request.getParam('name').trim()
|
|
35
38
|
if (!owner || !repo) {
|
|
36
|
-
return {
|
|
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 {
|
|
46
|
+
return response.json({ message: 'This organization is not configured for CI tracking.' }, 403)
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
const
|
|
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
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
31
|
-
const org = String(url.searchParams.get('org') ?? '').trim()
|
|
32
|
+
const org = dashboardRequestValue(request, 'org')
|
|
32
33
|
if (!org) {
|
|
33
|
-
return {
|
|
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 {
|
|
39
|
+
return response.json({ message: 'This organization is not configured for CI tracking.' }, 403)
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
const rawLimit = Number(
|
|
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 {
|
|
56
|
+
return response.json({ message: 'Runner history could not be loaded.' }, 503)
|
|
56
57
|
}
|
|
57
58
|
},
|
|
58
59
|
})
|