@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.
Files changed (28) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +42 -7
  3. package/ai/skills/stacks-payments/SKILL.md +11 -0
  4. package/app/Actions/Dashboard/DashboardHomeAction.test.ts +33 -1
  5. package/app/Actions/Dashboard/DashboardHomeAction.ts +63 -25
  6. package/app/Actions/Dashboard/Remote/RemoteCommandIndexAction.ts +3 -0
  7. package/app/Actions/Dashboard/Remote/RemoteTerminalCloseAction.ts +25 -0
  8. package/app/Actions/Dashboard/Remote/RemoteTerminalInputAction.ts +25 -0
  9. package/app/Actions/Dashboard/Remote/RemoteTerminalOpenAction.ts +39 -0
  10. package/app/Actions/Dashboard/Remote/RemoteTerminalResizeAction.ts +25 -0
  11. package/app/Actions/Dashboard/Remote/RemoteTerminalStreamAction.ts +36 -0
  12. package/app/Actions/Dashboard/Remote/remote-commands.ts +30 -1
  13. package/app/Actions/Dashboard/Remote/remote-routes.test.ts +4 -4
  14. package/app/Actions/Dashboard/Remote/remote-terminal.test.ts +463 -0
  15. package/app/Actions/Dashboard/Remote/remote-terminal.ts +399 -0
  16. package/app/Actions/Dashboard/Remote/ssh-runner.ts +82 -1
  17. package/app/Actions/Dashboard/Remote/terminal-sessions.ts +80 -0
  18. package/functions/dashboard-api.ts +13 -0
  19. package/functions/remote-terminal-element.ts +287 -0
  20. package/functions/remote.test.ts +52 -0
  21. package/functions/remote.ts +136 -0
  22. package/functions/xterm-styles.d.ts +9 -0
  23. package/ide/vscode/package.json +1 -1
  24. package/package.json +5 -3
  25. package/resources/components/Dashboard/Remote/RemoteConsole.stx +162 -0
  26. package/resources/functions/dashboard/sidebar.ts +3 -0
  27. package/routes/dashboard-api.ts +7 -0
  28. 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 650 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/.
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 407 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
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, 407 components, and a multi-section layout.
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. Deliberately NOT
132
- a terminal: the request names a host KEY and a command KEY, both from
133
- `config/remote.ts`, so there is nothing to escape and no shell to reach. An
134
- interactive session is tracked separately - `Bun.spawn` has no PTY, and
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 { Order, Post, Product, Request, User } from '@stacksjs/orm'
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 modelResults = await Promise.allSettled([
63
- User.count(),
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
- ] = modelResults
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 = ['Users', 'Products', 'Orders', 'Posts', 'Revenue', 'Recent orders', 'Recent users', 'Request count', 'Recent requests']
144
- const issues = modelResults
145
- .map((result, index) => issue(sources[index] ?? 'Dashboard', result))
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}`, '--', ...command.argv)
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 both endpoints', () => {
31
- expect(remoteRoutes).toHaveLength(2)
32
- expect(remoteRoutes.join('\n')).toContain('RemoteCommandIndexAction')
33
- expect(remoteRoutes.join('\n')).toContain('RemoteCommandRunAction')
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', () => {