@stacksjs/defaults 0.75.74 → 0.75.76
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-actions/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +42 -7
- package/ai/skills/stacks-payments/SKILL.md +11 -0
- package/app/Actions/Dashboard/DashboardHomeAction.test.ts +33 -1
- package/app/Actions/Dashboard/DashboardHomeAction.ts +63 -25
- package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +3 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalCloseAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalInputAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalOpenAction.ts +39 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalResizeAction.ts +25 -0
- package/app/Actions/Dashboard/Remote/RemoteTerminalStreamAction.ts +36 -0
- package/app/Actions/Dashboard/Remote/remote-commands.ts +30 -1
- package/app/Actions/Dashboard/Remote/remote-routes.test.ts +4 -4
- package/app/Actions/Dashboard/Remote/remote-terminal.test.ts +463 -0
- package/app/Actions/Dashboard/Remote/remote-terminal.ts +399 -0
- package/app/Actions/Dashboard/Remote/ssh-runner.ts +82 -1
- package/app/Actions/Dashboard/Remote/terminal-sessions.ts +80 -0
- package/functions/dashboard-api.ts +13 -0
- package/functions/remote-terminal-element.ts +287 -0
- package/functions/remote.test.ts +52 -0
- package/functions/remote.ts +136 -0
- package/functions/xterm-styles.d.ts +9 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +5 -3
- package/resources/components/Dashboard/Remote/RemoteConsole.stx +162 -0
- package/resources/functions/dashboard/sidebar.ts +3 -0
- package/routes/dashboard-api.ts +7 -0
- package/views/dashboard/remote/index.stx +8 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-actions
|
|
3
|
-
description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the
|
|
3
|
+
description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 657 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-dashboard
|
|
3
|
-
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the
|
|
3
|
+
description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 408 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Dashboard
|
|
10
10
|
|
|
11
|
-
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views,
|
|
11
|
+
The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 408 components, and a multi-section layout.
|
|
12
12
|
|
|
13
13
|
## Key Paths
|
|
14
14
|
- Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
|
|
@@ -128,11 +128,10 @@ already followed and a missing derivative is regenerated by reprocessing.
|
|
|
128
128
|
|
|
129
129
|
### Remote commands (stacksjs/stacks#960)
|
|
130
130
|
|
|
131
|
-
Running a configured operation on a configured host over SSH
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`ssh -tt` gives a remote one but cannot propagate a window resize.
|
|
131
|
+
Running a configured operation on a configured host over SSH: the request
|
|
132
|
+
names a host KEY and a command KEY, both from `config/remote.ts`, so there is
|
|
133
|
+
nothing to escape and no shell to reach. The interactive terminal beside it is
|
|
134
|
+
covered below.
|
|
136
135
|
|
|
137
136
|
Four things make it safe to expose, and each is a rule to keep:
|
|
138
137
|
|
|
@@ -156,6 +155,42 @@ command that hung and the one whose process died with the box. The audit sink
|
|
|
156
155
|
writes to the application log rather than the dashboard's own database, which is
|
|
157
156
|
the thing an operator with dashboard access could edit.
|
|
158
157
|
|
|
158
|
+
### Remote terminals (stacksjs/stacks#960)
|
|
159
|
+
|
|
160
|
+
`/remote` in the dashboard also opens an interactive shell on a host that sets
|
|
161
|
+
`terminal: true`. Every command-runner rule above holds, plus:
|
|
162
|
+
|
|
163
|
+
- **A host opts in, and a gate decides.** `terminal: true` on the host, and the
|
|
164
|
+
`open-remote-terminal` gate (receives the host key). No gate, no session.
|
|
165
|
+
- **A session belongs to the user who opened it.** 128-bit id; another user
|
|
166
|
+
naming it gets the same 404 as a missing id.
|
|
167
|
+
- **It does not outlive its purpose.** 15 minutes without input, 2 hours in all,
|
|
168
|
+
30 seconds after the last viewer leaves: the remote shell is killed, never
|
|
169
|
+
left running. At most 4 per user.
|
|
170
|
+
- **What is typed is audited line by line** in the application log, so hosts
|
|
171
|
+
should take keys and passwordless sudo, not typed passwords.
|
|
172
|
+
|
|
173
|
+
Transport: a local PTY (`Bun.spawn({ terminal })`, which Bun now has) running
|
|
174
|
+
`ssh -tt` with the pinned host key, so a browser resize reaches the remote side.
|
|
175
|
+
Output reaches the browser as server-sent events, base64 and sequence-numbered
|
|
176
|
+
for replay on reconnect; input and resize are small POSTs that each pass auth,
|
|
177
|
+
CSRF and the session-owner check. Sessions live in the server process.
|
|
178
|
+
|
|
179
|
+
In the browser it is `<stacks-remote-terminal host="...">` (xterm.js), from
|
|
180
|
+
`functions/remote-terminal-element.ts`. Two rules that module follows:
|
|
181
|
+
|
|
182
|
+
- **Nothing browser-only at module top level.** `defaults/functions` is also
|
|
183
|
+
auto-imported on the SERVER; a top-level `class extends HTMLElement` threw
|
|
184
|
+
there and took every function in the barrel down. xterm and the class load
|
|
185
|
+
inside `registerRemoteTerminal()`.
|
|
186
|
+
- **Survive being moved.** stx's `:if` detaches and re-inserts the SAME nodes, so
|
|
187
|
+
the element acts on a removal only if it is still detached a tick later, and
|
|
188
|
+
starts a fresh session whenever it is attached again.
|
|
189
|
+
|
|
190
|
+
`remote-terminal.test.ts` runs a real session through a throwaway user-level
|
|
191
|
+
`sshd` on a loopback port when one is installed (macOS and the CI image both
|
|
192
|
+
have one), including a host whose key does not match the pinned one.
|
|
193
|
+
|
|
159
194
|
**There is no ffmpeg.** #2578 asked whether video was in scope given the
|
|
160
195
|
external binary, its licensing and its provisioning; `@stacksjs/video` is built
|
|
161
196
|
on `ts-videos`, which encodes itself, so that question was already answered.
|
|
@@ -10,6 +10,17 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
10
10
|
|
|
11
11
|
Full Stripe integration via the Payment facade. Uses Stripe API version `2026-01-28.clover`. The Stripe SDK is initialized from `services.stripe.secretKey` (sourced from `config/payment.ts`).
|
|
12
12
|
|
|
13
|
+
## Payment Drivers (provider-neutral)
|
|
14
|
+
|
|
15
|
+
`paymentDriver()` (also `Payment.driver()`) returns the driver `config.payment.driver` selects: `stripe` (default) or `adyen`, or one added with `registerPaymentDriver(name, factory)`. Source: `storage/framework/core/payments/src/driver/`.
|
|
16
|
+
|
|
17
|
+
- Money is `{ amount, currency }` in minor units; `assertMoney` refuses floats before a request leaves.
|
|
18
|
+
- Methods: `customer`, `charge(payer, money, paymentMethodId)`, `createPayment` (browser completes: `clientConfirmation` is a Stripe client secret or an Adyen session), `refund`, `checkout(payer, { mode, lines, successUrl, ... })`, `paymentMethods`, `removePaymentMethod`, `subscribe`, `cancelSubscription`, `subscriptions`, `verifyWebhook({ payload, headers })` -> `PaymentEvent[]`, `acknowledgeWebhook()`.
|
|
19
|
+
- Unsupported operations throw `PaymentUnsupportedError` (driver + operation). Adyen has no catalog, subscriptions, invoices, coupons or portal.
|
|
20
|
+
- Adyen: Checkout API v72, `X-API-Key`; shopper reference `user-<id>` (no PII); full refunds go to `/reversals`; webhooks verified per item with HMAC-SHA256 over 8 colon-joined fields under the hex key, acknowledged with 202.
|
|
21
|
+
- The Stripe driver delegates to the existing billable modules, so idempotency keys and `stripe_id` handling are unchanged.
|
|
22
|
+
- Everything below this section is Stripe's own API and returns Stripe objects.
|
|
23
|
+
|
|
13
24
|
## Key Paths
|
|
14
25
|
- Core package: `storage/framework/core/payments/src/`
|
|
15
26
|
- Payment facade: `storage/framework/core/payments/src/payment.ts`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { orderActivityStatus, serializeHealthCheck, summarizeHttpRequests } from './DashboardHomeAction'
|
|
2
|
+
import { homeStats, orderActivityStatus, serializeHealthCheck, settleEach, summarizeHttpRequests } from './DashboardHomeAction'
|
|
3
3
|
|
|
4
4
|
describe('dashboard HTTP metrics', () => {
|
|
5
5
|
it('summarizes captured requests without fake values', () => {
|
|
@@ -39,3 +39,35 @@ describe('dashboard HTTP metrics', () => {
|
|
|
39
39
|
expect(orderActivityStatus('preparing')).toBe('success')
|
|
40
40
|
})
|
|
41
41
|
})
|
|
42
|
+
|
|
43
|
+
describe('dashboard home queries', () => {
|
|
44
|
+
it('turns a query that throws before returning a promise into a rejected entry', async () => {
|
|
45
|
+
// What `Product.count()` does with commerce off: the model is not loaded,
|
|
46
|
+
// `count` is undefined, and calling it throws synchronously. Evaluated as
|
|
47
|
+
// `Promise.allSettled([Product.count(), ...])` that escaped allSettled and
|
|
48
|
+
// returned a 500 for the whole page.
|
|
49
|
+
const unloaded = {} as { count: () => Promise<number> }
|
|
50
|
+
const [users, products] = await settleEach([async () => 3, () => unloaded.count()] as const)
|
|
51
|
+
|
|
52
|
+
expect(users).toEqual({ status: 'fulfilled', value: 3 })
|
|
53
|
+
expect(products.status).toBe('rejected')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows commerce numbers only when commerce is on', () => {
|
|
57
|
+
const users = { status: 'fulfilled', value: 12 } as const
|
|
58
|
+
expect(homeStats({ users }, false)).toEqual([{ label: 'Total Users', value: '12', color: 'blue' }])
|
|
59
|
+
|
|
60
|
+
const on = homeStats({
|
|
61
|
+
users,
|
|
62
|
+
products: { status: 'fulfilled', value: 4 },
|
|
63
|
+
revenue: { status: 'fulfilled', value: 1250 },
|
|
64
|
+
orders: { status: 'rejected', reason: new Error('down') },
|
|
65
|
+
}, true)
|
|
66
|
+
expect(on.map(stat => [stat.label, stat.value])).toEqual([
|
|
67
|
+
['Total Users', '12'],
|
|
68
|
+
['Products', '4'],
|
|
69
|
+
['Revenue', '$1,250'],
|
|
70
|
+
['Orders', 'Unavailable'],
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions/runtime'
|
|
2
|
-
import {
|
|
2
|
+
import { feature } from '@stacksjs/config'
|
|
3
|
+
import { Order, Product, Request, User } from '@stacksjs/orm'
|
|
3
4
|
import { checkApplicationHealth, type ApplicationHealthCheck } from '@stacksjs/router'
|
|
4
5
|
import { formatRelative, safeGet } from '../../../resources/functions/dashboard/data'
|
|
5
6
|
import { dashboardOperationalIssue } from './dashboard-response'
|
|
@@ -53,43 +54,71 @@ function issue(source: string, result: PromiseSettledResult<unknown>) {
|
|
|
53
54
|
: null
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Run each query inside the promise, so one that throws before returning one -
|
|
59
|
+
* `Product.count()` when commerce is off, where the model is not loaded and
|
|
60
|
+
* `count` is undefined - is a rejected entry rather than a 500 for the page.
|
|
61
|
+
* `Promise.allSettled([Product.count(), ...])` evaluated every call first.
|
|
62
|
+
*/
|
|
63
|
+
export function settleEach<T extends readonly (() => unknown)[]>(queries: T): Promise<{ [K in keyof T]: PromiseSettledResult<Awaited<ReturnType<T[K]>>> }> {
|
|
64
|
+
return Promise.allSettled(queries.map(query => Promise.resolve().then(query))) as never
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Settled = PromiseSettledResult<unknown>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The headline numbers. Products, revenue and orders are commerce's, so with
|
|
71
|
+
* commerce off they are left out rather than shown as "Unavailable" - there is
|
|
72
|
+
* nothing to be unavailable.
|
|
73
|
+
*/
|
|
74
|
+
export function homeStats(
|
|
75
|
+
results: { users: Settled, products?: Settled, revenue?: Settled, orders?: Settled },
|
|
76
|
+
commerce: boolean,
|
|
77
|
+
): Array<{ label: string, value: string, color: string }> {
|
|
78
|
+
const value = (result: Settled | undefined, format: (raw: unknown) => string) =>
|
|
79
|
+
result?.status === 'fulfilled' ? format(result.value) : 'Unavailable'
|
|
80
|
+
|
|
81
|
+
const stats = [{ label: 'Total Users', value: value(results.users, String), color: 'blue' }]
|
|
82
|
+
if (commerce) {
|
|
83
|
+
stats.push(
|
|
84
|
+
{ label: 'Products', value: value(results.products, String), color: 'green' },
|
|
85
|
+
{ label: 'Revenue', value: value(results.revenue, raw => `$${Number(raw || 0).toLocaleString()}`), color: 'orange' },
|
|
86
|
+
{ label: 'Orders', value: value(results.orders, String), color: 'red' },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return stats
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
export default new Action({
|
|
57
93
|
name: 'DashboardHomeAction',
|
|
58
94
|
description: 'Returns home dashboard data including stats, quick links, services, and recent activity.',
|
|
59
95
|
method: 'GET',
|
|
60
96
|
|
|
61
97
|
async handle() {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
Product.count(),
|
|
65
|
-
Order.count(),
|
|
66
|
-
Post.count(),
|
|
67
|
-
Order.sum('totalAmount'),
|
|
68
|
-
Order.orderBy('created_at', 'desc').limit(5).get(),
|
|
69
|
-
User.orderBy('created_at', 'desc').limit(5).get(),
|
|
70
|
-
Request.count(),
|
|
71
|
-
Request.orderBy('created_at', 'desc').limit(1000).get(),
|
|
72
|
-
])
|
|
73
|
-
const healthResult = await Promise.allSettled([checkApplicationHealth()])
|
|
74
|
-
|
|
98
|
+
const commerce = feature('commerce')
|
|
99
|
+
const none = async () => null
|
|
75
100
|
const [
|
|
76
101
|
userCount,
|
|
77
102
|
productCount,
|
|
78
103
|
orderCount,
|
|
79
|
-
postCount,
|
|
80
104
|
totalRevenue,
|
|
81
105
|
recentOrders,
|
|
82
106
|
recentUsers,
|
|
83
107
|
requestCount,
|
|
84
108
|
recentRequests,
|
|
85
|
-
] =
|
|
109
|
+
] = await settleEach([
|
|
110
|
+
() => User.count(),
|
|
111
|
+
commerce ? () => Product.count() : none,
|
|
112
|
+
commerce ? () => Order.count() : none,
|
|
113
|
+
commerce ? () => Order.sum('totalAmount') : none,
|
|
114
|
+
commerce ? () => Order.orderBy('created_at', 'desc').limit(5).get() : async () => [],
|
|
115
|
+
() => User.orderBy('created_at', 'desc').limit(5).get(),
|
|
116
|
+
() => Request.count(),
|
|
117
|
+
() => Request.orderBy('created_at', 'desc').limit(1000).get(),
|
|
118
|
+
] as const)
|
|
119
|
+
const healthResult = await Promise.allSettled([checkApplicationHealth()])
|
|
86
120
|
|
|
87
|
-
const stats =
|
|
88
|
-
{ label: 'Total Users', value: userCount.status === 'fulfilled' ? String(userCount.value) : 'Unavailable', color: 'blue' },
|
|
89
|
-
{ label: 'Products', value: productCount.status === 'fulfilled' ? String(productCount.value) : 'Unavailable', color: 'green' },
|
|
90
|
-
{ label: 'Revenue', value: totalRevenue.status === 'fulfilled' ? `$${Number(totalRevenue.value || 0).toLocaleString()}` : 'Unavailable', color: 'orange' },
|
|
91
|
-
{ label: 'Orders', value: orderCount.status === 'fulfilled' ? String(orderCount.value) : 'Unavailable', color: 'red' },
|
|
92
|
-
]
|
|
121
|
+
const stats = homeStats({ users: userCount, products: productCount, revenue: totalRevenue, orders: orderCount }, commerce)
|
|
93
122
|
|
|
94
123
|
const httpMetrics = requestCount.status === 'fulfilled' && recentRequests.status === 'fulfilled'
|
|
95
124
|
? summarizeHttpRequests(requestCount.value, recentRequests.value.map(request => ({
|
|
@@ -140,9 +169,18 @@ export default new Action({
|
|
|
140
169
|
status: activity.status,
|
|
141
170
|
}))
|
|
142
171
|
|
|
143
|
-
const sources
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
const sources: Array<[string, PromiseSettledResult<unknown>]> = [
|
|
173
|
+
['Users', userCount],
|
|
174
|
+
['Products', productCount],
|
|
175
|
+
['Orders', orderCount],
|
|
176
|
+
['Revenue', totalRevenue],
|
|
177
|
+
['Recent orders', recentOrders],
|
|
178
|
+
['Recent users', recentUsers],
|
|
179
|
+
['Request count', requestCount],
|
|
180
|
+
['Recent requests', recentRequests],
|
|
181
|
+
]
|
|
182
|
+
const issues = sources
|
|
183
|
+
.map(([source, result]) => issue(source, result))
|
|
146
184
|
.filter((entry): entry is { source: string, message: string } => entry !== null)
|
|
147
185
|
if (health.status === 'rejected') {
|
|
148
186
|
issues.push({
|
|
@@ -17,6 +17,9 @@ export default new Action({
|
|
|
17
17
|
host: host.host,
|
|
18
18
|
user: host.user,
|
|
19
19
|
port: host.port ?? 22,
|
|
20
|
+
// Whether the host allows terminal sessions at all. Whether this
|
|
21
|
+
// user may open one is the `open-remote-terminal` gate's call.
|
|
22
|
+
terminal: host.terminal === true,
|
|
20
23
|
})),
|
|
21
24
|
commands: commands.map(command => ({
|
|
22
25
|
key: command.key,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { RemoteCommandError } from './remote-commands'
|
|
6
|
+
import { terminalSessions } from './terminal-sessions'
|
|
7
|
+
|
|
8
|
+
/** End a terminal session and the remote shell with it (stacksjs/stacks#960). */
|
|
9
|
+
export default new Action({
|
|
10
|
+
name: 'RemoteTerminalCloseAction',
|
|
11
|
+
description: 'Closes a terminal session.',
|
|
12
|
+
method: 'DELETE',
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
try {
|
|
15
|
+
const user = (await request.user() ?? null) as UserModel | null
|
|
16
|
+
await terminalSessions.close(request.getParam('id'), user)
|
|
17
|
+
return response.json({ ok: true })
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof RemoteCommandError)
|
|
21
|
+
return response.json({ message: error.message }, error.status)
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { RemoteCommandError } from './remote-commands'
|
|
6
|
+
import { terminalSessions } from './terminal-sessions'
|
|
7
|
+
|
|
8
|
+
/** Type into a terminal session (stacksjs/stacks#960). */
|
|
9
|
+
export default new Action({
|
|
10
|
+
name: 'RemoteTerminalInputAction',
|
|
11
|
+
description: 'Sends input to a terminal session.',
|
|
12
|
+
method: 'POST',
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
try {
|
|
15
|
+
const user = (await request.user() ?? null) as UserModel | null
|
|
16
|
+
await terminalSessions.input(request.getParam('id'), user, request.get('data'))
|
|
17
|
+
return response.json({ ok: true })
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof RemoteCommandError)
|
|
21
|
+
return response.json({ message: error.message }, error.status)
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { hosts } from '~/config/remote'
|
|
5
|
+
import { response } from '@stacksjs/router'
|
|
6
|
+
import { RemoteCommandError } from './remote-commands'
|
|
7
|
+
import { terminalAuthorizer, terminalSessions } from './terminal-sessions'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open a terminal session on a configured host (stacksjs/stacks#960).
|
|
11
|
+
*
|
|
12
|
+
* Routed without `guard()`, like the command runner: that helper drops auth
|
|
13
|
+
* entirely when `APP_ENV` is local or test, which here would be an
|
|
14
|
+
* unauthenticated shell. See `routes/dashboard-api.ts`.
|
|
15
|
+
*/
|
|
16
|
+
export default new Action({
|
|
17
|
+
name: 'RemoteTerminalOpenAction',
|
|
18
|
+
description: 'Opens an interactive terminal session on a configured host.',
|
|
19
|
+
method: 'POST',
|
|
20
|
+
async handle(request: RequestInstance) {
|
|
21
|
+
try {
|
|
22
|
+
const user = (await request.user() ?? null) as UserModel | null
|
|
23
|
+
const session = await terminalSessions.open({
|
|
24
|
+
user,
|
|
25
|
+
hosts,
|
|
26
|
+
hostKey: request.get('host'),
|
|
27
|
+
authorizer: terminalAuthorizer(),
|
|
28
|
+
cols: request.get('cols'),
|
|
29
|
+
rows: request.get('rows'),
|
|
30
|
+
})
|
|
31
|
+
return response.json(session, 201)
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof RemoteCommandError)
|
|
35
|
+
return response.json({ message: error.message }, error.status)
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { RemoteCommandError } from './remote-commands'
|
|
6
|
+
import { terminalSessions } from './terminal-sessions'
|
|
7
|
+
|
|
8
|
+
/** Resize a terminal session's window (stacksjs/stacks#960). */
|
|
9
|
+
export default new Action({
|
|
10
|
+
name: 'RemoteTerminalResizeAction',
|
|
11
|
+
description: 'Resizes a terminal session.',
|
|
12
|
+
method: 'POST',
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
try {
|
|
15
|
+
const user = (await request.user() ?? null) as UserModel | null
|
|
16
|
+
terminalSessions.resize(request.getParam('id'), user, request.get('cols'), request.get('rows'))
|
|
17
|
+
return response.json({ ok: true })
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof RemoteCommandError)
|
|
21
|
+
return response.json({ message: error.message }, error.status)
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { UserModel } from '@stacksjs/orm'
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
3
|
+
import { Action } from '@stacksjs/actions/runtime'
|
|
4
|
+
import { response, stream } from '@stacksjs/router'
|
|
5
|
+
import { RemoteCommandError } from './remote-commands'
|
|
6
|
+
import { terminalEventStream, terminalSessions } from './terminal-sessions'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A terminal session's output as server-sent events (stacksjs/stacks#960).
|
|
10
|
+
*
|
|
11
|
+
* `after` (or `Last-Event-ID`) resumes after the last sequence number the
|
|
12
|
+
* viewer saw, from the session's replay buffer.
|
|
13
|
+
*/
|
|
14
|
+
export default new Action({
|
|
15
|
+
name: 'RemoteTerminalStreamAction',
|
|
16
|
+
description: 'Streams a terminal session\'s output.',
|
|
17
|
+
method: 'GET',
|
|
18
|
+
async handle(request: RequestInstance) {
|
|
19
|
+
try {
|
|
20
|
+
const user = (await request.user() ?? null) as UserModel | null
|
|
21
|
+
const resumeFrom = request.get('after') ?? request.headers.get('last-event-id') ?? 0
|
|
22
|
+
const after = Number(resumeFrom)
|
|
23
|
+
const events = terminalSessions.watch(request.getParam('id'), user, Number.isInteger(after) && after > 0 ? after : 0)
|
|
24
|
+
return stream(terminalEventStream(events), {
|
|
25
|
+
type: 'sse',
|
|
26
|
+
// Proxies that buffer would hold keystroke echoes until a buffer fills.
|
|
27
|
+
headers: { 'X-Accel-Buffering': 'no' },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof RemoteCommandError)
|
|
32
|
+
return response.json({ message: error.message }, error.status)
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -62,6 +62,15 @@ export interface RemoteHost {
|
|
|
62
62
|
* the host can declare its fingerprint at the same time.
|
|
63
63
|
*/
|
|
64
64
|
knownHosts: string
|
|
65
|
+
/**
|
|
66
|
+
* Allow interactive terminal sessions into this host from the dashboard.
|
|
67
|
+
*
|
|
68
|
+
* Off unless set. A host declared for named commands is not thereby a host
|
|
69
|
+
* anyone may open a shell on: a terminal is every command at once, so it is
|
|
70
|
+
* a separate decision, and authorized by its own gate,
|
|
71
|
+
* `open-remote-terminal`.
|
|
72
|
+
*/
|
|
73
|
+
terminal?: boolean
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/** An operation that may be run, as config declares it. */
|
|
@@ -144,6 +153,26 @@ const DEFAULT_MAX_OUTPUT = 64 * 1024
|
|
|
144
153
|
* interpreted by a local shell.
|
|
145
154
|
*/
|
|
146
155
|
export function sshArgv(host: RemoteHost, command: RemoteCommand, knownHostsPath: string): string[] {
|
|
156
|
+
return [...sshConnectionArgv(host, knownHostsPath), '--', ...command.argv]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The `ssh` argv for an interactive session: the same pinned-key options and
|
|
161
|
+
* no remote command, so the host starts the user's login shell.
|
|
162
|
+
*
|
|
163
|
+
* `-tt` forces a remote PTY. ssh would allocate one anyway when its own stdin
|
|
164
|
+
* is a terminal, which it is - the session runs under a local PTY so a window
|
|
165
|
+
* resize reaches the remote side - but forcing it keeps the remote behaviour
|
|
166
|
+
* independent of how the local side happens to be wired.
|
|
167
|
+
*/
|
|
168
|
+
export function sshTerminalArgv(host: RemoteHost, knownHostsPath: string): string[] {
|
|
169
|
+
const argv = sshConnectionArgv(host, knownHostsPath)
|
|
170
|
+
// Before the destination: options after it would be read as the command.
|
|
171
|
+
argv.splice(argv.length - 1, 0, '-tt', '-o', 'ServerAliveInterval=30')
|
|
172
|
+
return argv
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sshConnectionArgv(host: RemoteHost, knownHostsPath: string): string[] {
|
|
147
176
|
const argv = [
|
|
148
177
|
'ssh',
|
|
149
178
|
'-o', 'StrictHostKeyChecking=yes',
|
|
@@ -157,7 +186,7 @@ export function sshArgv(host: RemoteHost, command: RemoteCommand, knownHostsPath
|
|
|
157
186
|
if (host.identityFile)
|
|
158
187
|
argv.push('-i', host.identityFile, '-o', 'IdentitiesOnly=yes')
|
|
159
188
|
|
|
160
|
-
argv.push(`${host.user}@${host.host}
|
|
189
|
+
argv.push(`${host.user}@${host.host}`)
|
|
161
190
|
return argv
|
|
162
191
|
}
|
|
163
192
|
|
|
@@ -27,10 +27,10 @@ const remoteRoutes = routes
|
|
|
27
27
|
.filter(line => line.includes('Actions/Dashboard/Remote/'))
|
|
28
28
|
|
|
29
29
|
describe('remote-command routes', () => {
|
|
30
|
-
it('registers
|
|
31
|
-
expect(remoteRoutes).toHaveLength(
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
it('registers the command and terminal endpoints', () => {
|
|
31
|
+
expect(remoteRoutes).toHaveLength(7)
|
|
32
|
+
for (const action of ['RemoteCommandIndexAction', 'RemoteCommandRunAction', 'RemoteTerminalOpenAction', 'RemoteTerminalStreamAction', 'RemoteTerminalInputAction', 'RemoteTerminalResizeAction', 'RemoteTerminalCloseAction'])
|
|
33
|
+
expect(remoteRoutes.join('\n')).toContain(action)
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
it('never uses the guard that drops auth in local environments', () => {
|