@stacksjs/defaults 0.75.74 → 0.75.75

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
@@ -0,0 +1,287 @@
1
+ import type { FitAddon } from '@xterm/addon-fit'
2
+ import type { Terminal } from '@xterm/xterm'
3
+ import { DashboardApiError } from './dashboard-api'
4
+ import { closeTerminalSession, openTerminalSession, resizeTerminalSession, sendTerminalInput, streamTerminal } from './remote'
5
+
6
+ /**
7
+ * `<stacks-remote-terminal host="app">` - an interactive terminal session on a
8
+ * configured host, rendered with xterm.js (stacksjs/stacks#960).
9
+ *
10
+ * The session opens when the element is attached and closes when it is
11
+ * removed, so leaving the page ends the remote shell instead of leaving it
12
+ * running for the server's detach grace. Output arrives as server-sent events
13
+ * read through `fetch`; keystrokes go out as small POSTs, batched and sent one
14
+ * at a time so they arrive in the order they were typed.
15
+ *
16
+ * Fires `terminal-open` with `{ id }` and `terminal-exit` with
17
+ * `{ reason, exitCode, message }`.
18
+ *
19
+ * Everything browser-only - xterm, its stylesheet, the `HTMLElement` subclass -
20
+ * is loaded inside {@link registerRemoteTerminal}. This module is also loaded
21
+ * on the server, by the auto-import barrel for this directory: a class
22
+ * extending `HTMLElement` at the top level threw there and took every function
23
+ * in the barrel down with it, and xterm left a handle open that would keep a
24
+ * CLI process from exiting.
25
+ */
26
+
27
+ const STYLE_ID = 'stacks-xterm-styles'
28
+ const MAX_RECONNECTS = 5
29
+
30
+ interface Xterm {
31
+ Terminal: typeof Terminal
32
+ FitAddon: typeof FitAddon
33
+ styles: string
34
+ }
35
+
36
+ function ensureStyles(styles: string): void {
37
+ if (document.getElementById(STYLE_ID))
38
+ return
39
+ const style = document.createElement('style')
40
+ style.id = STYLE_ID
41
+ style.textContent = styles
42
+ document.head.append(style)
43
+ }
44
+
45
+ function defineElement(xterm: Xterm) {
46
+ return class StacksRemoteTerminal extends HTMLElement {
47
+ static observedAttributes = ['host']
48
+
49
+ /**
50
+ * Bumped by every start and teardown. Opening a session is asynchronous,
51
+ * and the element can be detached, re-attached or pointed at another host
52
+ * meanwhile; a continuation from an older lifetime sees the number has
53
+ * moved on and closes what it opened instead of adopting it.
54
+ */
55
+ private generation = 0
56
+ private running?: string
57
+ private terminal?: Terminal
58
+ private fit?: FitAddon
59
+ private session?: string
60
+ private abort = new AbortController()
61
+ private observer?: ResizeObserver
62
+ private seq = 0
63
+ private pending = ''
64
+ private sending = false
65
+ private ended = false
66
+ private resizeTimer?: ReturnType<typeof setTimeout>
67
+
68
+ connectedCallback(): void {
69
+ void this.start()
70
+ }
71
+
72
+ /**
73
+ * A template may set `host` after inserting the element, so this starts it
74
+ * too; and a different host on a running element is a new session there.
75
+ */
76
+ attributeChangedCallback(_name: string, previous: string | null, next: string | null): void {
77
+ if (this.running !== undefined && previous !== next)
78
+ this.teardown()
79
+ void this.start()
80
+ }
81
+
82
+ /**
83
+ * A template may detach an element and attach it again - stx's `:if` keeps
84
+ * the hidden nodes and re-inserts the same ones. So a removal is acted on
85
+ * a tick later, and only if the element is still out of the document; and
86
+ * once torn down, the next attach starts a fresh session.
87
+ */
88
+ disconnectedCallback(): void {
89
+ setTimeout(() => {
90
+ if (!this.isConnected)
91
+ this.teardown()
92
+ }, 0)
93
+ }
94
+
95
+ private async start(): Promise<void> {
96
+ const host = this.getAttribute('host')
97
+ if (this.running !== undefined || !this.isConnected || !host)
98
+ return
99
+ this.running = host
100
+ const generation = ++this.generation
101
+ this.abort = new AbortController()
102
+ this.seq = 0
103
+ this.pending = ''
104
+ this.sending = false
105
+ this.ended = false
106
+ this.session = undefined
107
+ ensureStyles(xterm.styles)
108
+
109
+ const surface = document.createElement('div')
110
+ surface.style.width = '100%'
111
+ surface.style.height = '100%'
112
+ this.replaceChildren(surface)
113
+
114
+ const terminal = new xterm.Terminal({
115
+ cursorBlink: true,
116
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
117
+ fontSize: 13,
118
+ scrollback: 5000,
119
+ theme: { background: '#0a0a0a' },
120
+ })
121
+ const fit = new xterm.FitAddon()
122
+ terminal.loadAddon(fit)
123
+ terminal.open(surface)
124
+ fit.fit()
125
+ this.terminal = terminal
126
+ this.fit = fit
127
+
128
+ let id: string
129
+ try {
130
+ id = (await openTerminalSession(host, terminal.cols, terminal.rows)).id
131
+ }
132
+ catch (error) {
133
+ if (generation === this.generation)
134
+ this.finish('refused', null, error instanceof Error ? error.message : String(error))
135
+ return
136
+ }
137
+
138
+ // Torn down or re-hosted while the session was opening: close it rather
139
+ // than leave a shell nobody is attached to.
140
+ if (generation !== this.generation) {
141
+ void closeTerminalSession(id).catch(() => {})
142
+ return
143
+ }
144
+ this.session = id
145
+
146
+ terminal.onData(data => this.type(data))
147
+ terminal.onResize(({ cols, rows }) => {
148
+ clearTimeout(this.resizeTimer)
149
+ this.resizeTimer = setTimeout(() => {
150
+ if (this.session && !this.ended)
151
+ void resizeTerminalSession(this.session, cols, rows).catch(() => {})
152
+ }, 150)
153
+ })
154
+ this.observer = new ResizeObserver(() => this.fit?.fit())
155
+ this.observer.observe(this)
156
+
157
+ this.dispatchEvent(new CustomEvent('terminal-open', { detail: { id } }))
158
+ terminal.focus()
159
+ void this.pump()
160
+ }
161
+
162
+ private teardown(): void {
163
+ this.generation++
164
+ this.abort.abort()
165
+ this.observer?.disconnect()
166
+ this.observer = undefined
167
+ clearTimeout(this.resizeTimer)
168
+ if (this.session && !this.ended)
169
+ void closeTerminalSession(this.session).catch(() => {})
170
+ this.session = undefined
171
+ this.ended = true
172
+ this.terminal?.dispose()
173
+ this.terminal = undefined
174
+ this.fit = undefined
175
+ this.running = undefined
176
+ this.replaceChildren()
177
+ }
178
+
179
+ /**
180
+ * Read output until the session ends, reconnecting from the last sequence
181
+ * seen. Bound to the lifetime that started it: a restart gives the next
182
+ * session its own pump, and this one stops.
183
+ */
184
+ private async pump(): Promise<void> {
185
+ const generation = this.generation
186
+ const { signal } = this.abort
187
+ const session = this.session!
188
+ const current = () => generation === this.generation && !this.ended
189
+ let failures = 0
190
+ while (current()) {
191
+ try {
192
+ for await (const event of streamTerminal(session, this.seq, signal)) {
193
+ failures = 0
194
+ if (event.type === 'output') {
195
+ this.seq = event.seq
196
+ this.terminal?.write(event.data)
197
+ }
198
+ else {
199
+ this.finish(event.reason, event.exitCode)
200
+ return
201
+ }
202
+ }
203
+ }
204
+ catch (error) {
205
+ if (signal.aborted || !current())
206
+ return
207
+ if (error instanceof DashboardApiError && error.status === 404) {
208
+ this.finish('ended', null)
209
+ return
210
+ }
211
+ }
212
+
213
+ if (!current())
214
+ return
215
+ if (++failures > MAX_RECONNECTS) {
216
+ this.finish('disconnected', null, 'The connection to the server was lost.')
217
+ return
218
+ }
219
+ await new Promise(resolve => setTimeout(resolve, Math.min(500 * 2 ** failures, 8000)))
220
+ }
221
+ }
222
+
223
+ /** Batch keystrokes, one request in flight at a time, so they arrive in order. */
224
+ private type(data: string): void {
225
+ if (this.ended || !this.session)
226
+ return
227
+ this.pending += data
228
+ if (!this.sending)
229
+ void this.flush()
230
+ }
231
+
232
+ private async flush(): Promise<void> {
233
+ const generation = this.generation
234
+ const session = this.session!
235
+ this.sending = true
236
+ try {
237
+ while (this.pending && !this.ended && generation === this.generation) {
238
+ const data = this.pending
239
+ this.pending = ''
240
+ try {
241
+ await sendTerminalInput(session, data)
242
+ }
243
+ catch (error) {
244
+ if (error instanceof DashboardApiError && error.status === 404 && generation === this.generation)
245
+ this.finish('ended', null)
246
+ }
247
+ }
248
+ }
249
+ finally {
250
+ if (generation === this.generation)
251
+ this.sending = false
252
+ }
253
+ }
254
+
255
+ private finish(reason: string, exitCode: number | null, message?: string): void {
256
+ if (this.ended)
257
+ return
258
+ this.ended = true
259
+ this.abort.abort()
260
+ const note = message ?? (exitCode === null ? `session ${reason}` : `session ${reason}, exit ${exitCode}`)
261
+ this.terminal?.write(`\r\n\x1B[2m[${note}]\x1B[0m\r\n`)
262
+ this.dispatchEvent(new CustomEvent('terminal-exit', { detail: { reason, exitCode, message } }))
263
+ }
264
+ }
265
+ }
266
+
267
+ let registering: Promise<void> | undefined
268
+
269
+ /**
270
+ * Load xterm and define the element, once. Safe to call from every component
271
+ * that uses it, and a no-op outside a browser. Elements already in the page
272
+ * upgrade when the definition lands.
273
+ */
274
+ export function registerRemoteTerminal(): Promise<void> {
275
+ if (typeof customElements === 'undefined')
276
+ return Promise.resolve()
277
+ registering ??= (async () => {
278
+ const [{ Terminal }, { FitAddon }, styles] = await Promise.all([
279
+ import('@xterm/xterm'),
280
+ import('@xterm/addon-fit'),
281
+ import('@xterm/xterm/css/xterm.css', { with: { type: 'text' } }).then(module => module.default as string),
282
+ ])
283
+ if (!customElements.get('stacks-remote-terminal'))
284
+ customElements.define('stacks-remote-terminal', defineElement({ Terminal, FitAddon, styles }))
285
+ })()
286
+ return registering
287
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { encodeTerminalEvent } from '../app/Actions/Dashboard/Remote/terminal-sessions'
3
+ import { parseTerminalEvents } from './remote'
4
+
5
+ /**
6
+ * The dashboard reads a terminal's server-sent events through `fetch`, because
7
+ * `EventSource` cannot send its bearer token, so it parses the stream itself.
8
+ * Encoded by the server's own encoder, split anywhere the network might.
9
+ */
10
+ function streamOf(text: string, chunkSize: number): ReadableStream<Uint8Array> {
11
+ const bytes = new TextEncoder().encode(text)
12
+ let offset = 0
13
+ return new ReadableStream({
14
+ pull(controller) {
15
+ if (offset >= bytes.length)
16
+ return controller.close()
17
+ controller.enqueue(bytes.slice(offset, offset + chunkSize))
18
+ offset += chunkSize
19
+ },
20
+ })
21
+ }
22
+
23
+ async function collect(stream: ReadableStream<Uint8Array>) {
24
+ const events = []
25
+ for await (const event of parseTerminalEvents(stream))
26
+ events.push(event)
27
+ return events
28
+ }
29
+
30
+ describe('parseTerminalEvents', () => {
31
+ // "é" is two bytes, delivered in two output events, as a PTY can.
32
+ const wire = [
33
+ ': connected\n\n',
34
+ encodeTerminalEvent({ type: 'output', seq: 1, data: new Uint8Array([0x68, 0x69, 0x20, 0xC3]) }),
35
+ ': ping\n\n',
36
+ encodeTerminalEvent({ type: 'output', seq: 2, data: new Uint8Array([0xA9]) }),
37
+ encodeTerminalEvent({ type: 'exit', reason: 'exited', exitCode: 3 }),
38
+ ].join('')
39
+
40
+ test('reads output, skips heartbeats, and ends on exit, however the bytes are split', async () => {
41
+ for (const chunkSize of [1, 3, 7, 64, wire.length]) {
42
+ const events = await collect(streamOf(wire, chunkSize))
43
+ expect(events.map(event => event.type)).toEqual(['output', 'output', 'exit'])
44
+ expect(events[2]).toEqual({ type: 'exit', reason: 'exited', exitCode: 3 })
45
+
46
+ const [first, second] = events as Array<{ seq: number, data: Uint8Array }>
47
+ expect([first!.seq, second!.seq]).toEqual([1, 2])
48
+ const joined = new Uint8Array([...first!.data, ...second!.data])
49
+ expect(new TextDecoder().decode(joined)).toBe('hi é')
50
+ }
51
+ })
52
+ })
@@ -0,0 +1,136 @@
1
+ import { dashboardApi, dashboardStream } from './dashboard-api'
2
+
3
+ /**
4
+ * The dashboard's remote hosts: named commands, and interactive terminals on
5
+ * the hosts that allow them (stacksjs/stacks#960).
6
+ */
7
+
8
+ export interface RemoteHostSummary {
9
+ key: string
10
+ host: string
11
+ user: string
12
+ port: number
13
+ terminal: boolean
14
+ }
15
+
16
+ export interface RemoteCommandSummary {
17
+ key: string
18
+ description: string
19
+ argv: string[]
20
+ hosts: string[] | null
21
+ }
22
+
23
+ export interface RemoteRegistry {
24
+ hosts: RemoteHostSummary[]
25
+ commands: RemoteCommandSummary[]
26
+ }
27
+
28
+ export interface RemoteCommandResult {
29
+ host: string
30
+ command: string
31
+ exitCode: number
32
+ stdout: string
33
+ stderr: string
34
+ durationMs: number
35
+ timedOut: boolean
36
+ }
37
+
38
+ export type TerminalStreamEvent =
39
+ | { type: 'output', seq: number, data: Uint8Array }
40
+ | { type: 'exit', reason: string, exitCode: number | null }
41
+
42
+ export function fetchRemoteRegistry(): Promise<RemoteRegistry> {
43
+ return dashboardApi<RemoteRegistry>('/api/dashboard/remote/commands')
44
+ }
45
+
46
+ export function requestRemoteCommand(host: string, command: string): Promise<RemoteCommandResult> {
47
+ return dashboardApi<RemoteCommandResult>('/api/dashboard/remote/run', { method: 'POST', body: { host, command } })
48
+ }
49
+
50
+ export function openTerminalSession(host: string, cols: number, rows: number): Promise<{ id: string, host: string }> {
51
+ return dashboardApi('/api/dashboard/remote/terminals', { method: 'POST', body: { host, cols, rows } })
52
+ }
53
+
54
+ export function sendTerminalInput(id: string, data: string): Promise<unknown> {
55
+ return dashboardApi(`/api/dashboard/remote/terminals/${encodeURIComponent(id)}/input`, { method: 'POST', body: { data } })
56
+ }
57
+
58
+ export function resizeTerminalSession(id: string, cols: number, rows: number): Promise<unknown> {
59
+ return dashboardApi(`/api/dashboard/remote/terminals/${encodeURIComponent(id)}/resize`, { method: 'POST', body: { cols, rows } })
60
+ }
61
+
62
+ export function closeTerminalSession(id: string): Promise<unknown> {
63
+ return dashboardApi(`/api/dashboard/remote/terminals/${encodeURIComponent(id)}`, { method: 'DELETE' })
64
+ }
65
+
66
+ /**
67
+ * A session's events, from after sequence `after`, until the session ends or
68
+ * `signal` aborts. Ends without an exit event when the connection drops, so
69
+ * the caller can reconnect from the last sequence it saw.
70
+ */
71
+ export async function* streamTerminal(id: string, after: number, signal: AbortSignal): AsyncGenerator<TerminalStreamEvent> {
72
+ const res = await dashboardStream(`/api/dashboard/remote/terminals/${encodeURIComponent(id)}/stream?after=${after}`, { signal })
73
+ if (!res.body)
74
+ return
75
+ yield* parseTerminalEvents(res.body)
76
+ }
77
+
78
+ function decodeBase64(value: string): Uint8Array {
79
+ const binary = atob(value)
80
+ const bytes = new Uint8Array(binary.length)
81
+ for (let i = 0; i < binary.length; i++)
82
+ bytes[i] = binary.charCodeAt(i)
83
+ return bytes
84
+ }
85
+
86
+ /**
87
+ * Server-sent events from a byte stream, however the network splits them.
88
+ * Comments (the heartbeat) and unknown events are skipped.
89
+ */
90
+ export async function* parseTerminalEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<TerminalStreamEvent> {
91
+ const decoder = new TextDecoder()
92
+ const reader = body.getReader()
93
+ let buffer = ''
94
+
95
+ try {
96
+ while (true) {
97
+ const { value, done } = await reader.read()
98
+ if (done)
99
+ return
100
+ buffer += decoder.decode(value, { stream: true })
101
+
102
+ let boundary = buffer.indexOf('\n\n')
103
+ while (boundary !== -1) {
104
+ const block = buffer.slice(0, boundary)
105
+ buffer = buffer.slice(boundary + 2)
106
+ boundary = buffer.indexOf('\n\n')
107
+
108
+ let event = 'message'
109
+ let id = ''
110
+ const data: string[] = []
111
+ for (const line of block.split('\n')) {
112
+ if (line.startsWith(':'))
113
+ continue
114
+ const colon = line.indexOf(':')
115
+ const field = colon === -1 ? line : line.slice(0, colon)
116
+ const raw = colon === -1 ? '' : line.slice(colon + 1)
117
+ const content = raw.startsWith(' ') ? raw.slice(1) : raw
118
+ if (field === 'event')
119
+ event = content
120
+ else if (field === 'id')
121
+ id = content
122
+ else if (field === 'data')
123
+ data.push(content)
124
+ }
125
+
126
+ if (event === 'output')
127
+ yield { type: 'output', seq: Number(id), data: decodeBase64(data.join('')) }
128
+ else if (event === 'exit')
129
+ yield { type: 'exit', ...(JSON.parse(data.join('\n')) as { reason: string, exitCode: number | null }) }
130
+ }
131
+ }
132
+ }
133
+ finally {
134
+ reader.releaseLock()
135
+ }
136
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * xterm's stylesheet, imported as text by `remote-terminal-element.ts` (Bun's
3
+ * `with { type: 'text' }`). Declared for this one module rather than every
4
+ * `*.css`, so it does not claim anything about how other stylesheets load.
5
+ */
6
+ declare module '@xterm/xterm/css/xterm.css' {
7
+ const styles: string
8
+ export default styles
9
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.74",
5
+ "version": "0.75.75",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.74",
5
+ "version": "0.75.75",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,8 +55,10 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.74",
59
- "@stacksjs/sanitizer": "^0.2.374",
58
+ "@stacksjs/mobile": "^0.75.75",
59
+ "@stacksjs/sanitizer": "^0.2.378",
60
+ "@xterm/addon-fit": "^0.11.0",
61
+ "@xterm/xterm": "^6.0.0",
60
62
  "ts-qr-codes": "^0.1.8"
61
63
  }
62
64
  }
@@ -0,0 +1,162 @@
1
+ <script client>
2
+ import type { RemoteCommandResult, RemoteRegistry } from '../../../../functions/remote'
3
+ import { fetchRemoteRegistry, requestRemoteCommand } from '../../../../functions/remote'
4
+ import { registerRemoteTerminal } from '../../../../functions/remote-terminal-element'
5
+ import { pushToast } from '../../../../functions/toasts'
6
+
7
+ void registerRemoteTerminal()
8
+
9
+ const registry = state<RemoteRegistry | null>(null)
10
+ const isLoading = state(true)
11
+ const loadError = state('')
12
+ const selectedHost = state('')
13
+ const selectedCommand = state('')
14
+ const isRunning = state(false)
15
+ const result = state<RemoteCommandResult | null>(null)
16
+ const terminalHost = state('')
17
+
18
+ // Template expressions are evaluated as JavaScript, not TypeScript: no `!`.
19
+ const hosts = derived(() => registry()?.hosts ?? [])
20
+ const commands = derived(() => registry()?.commands ?? [])
21
+
22
+ const commandsForHost = derived(() => {
23
+ const host = selectedHost()
24
+ return (registry()?.commands ?? []).filter(command => !command.hosts || command.hosts.includes(host))
25
+ })
26
+
27
+ async function load(): Promise<void> {
28
+ isLoading.set(true)
29
+ loadError.set('')
30
+ try {
31
+ const data = await fetchRemoteRegistry()
32
+ registry.set(data)
33
+ if (data.hosts.length > 0)
34
+ selectedHost.set(data.hosts[0].key)
35
+ }
36
+ catch (error) {
37
+ loadError.set(error instanceof Error ? error.message : 'The remote hosts could not be loaded.')
38
+ }
39
+ finally {
40
+ isLoading.set(false)
41
+ }
42
+ }
43
+
44
+ async function run(): Promise<void> {
45
+ if (!selectedHost() || !selectedCommand() || isRunning())
46
+ return
47
+ isRunning.set(true)
48
+ result.set(null)
49
+ try {
50
+ result.set(await requestRemoteCommand(selectedHost(), selectedCommand()))
51
+ }
52
+ catch (error) {
53
+ pushToast('error', 'The command did not run', { detail: error instanceof Error ? error.message : String(error) })
54
+ }
55
+ finally {
56
+ isRunning.set(false)
57
+ }
58
+ }
59
+
60
+ function showTerminal(host: string): void {
61
+ terminalHost.set(host)
62
+ }
63
+
64
+ function hideTerminal(): void {
65
+ terminalHost.set('')
66
+ }
67
+
68
+ function onTerminalExit(event: CustomEvent<{ reason: string, message?: string }>): void {
69
+ if (event.detail.reason === 'refused')
70
+ pushToast('error', 'The terminal could not open', { detail: event.detail.message ?? '' })
71
+ }
72
+
73
+ onMount(() => {
74
+ void load()
75
+ })
76
+ </script>
77
+
78
+ <div class="space-y-6">
79
+ <header>
80
+ <h1 class="font-bold text-2xl text-gray-900 dark:text-white">Remote</h1>
81
+ <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Run configured operations on configured hosts, or open a terminal on a host that allows one. Every run and session is recorded.</p>
82
+ </header>
83
+
84
+ <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">
85
+ {{ loadError() }}
86
+ </div>
87
+
88
+ <div :if="isLoading()" class="text-gray-500 text-sm dark:text-neutral-400">Loading hosts</div>
89
+
90
+ <section :if="!isLoading() && registry() && hosts().length === 0" class="p-6 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
91
+ <h2 class="font-semibold text-gray-900 dark:text-white">No hosts are configured</h2>
92
+ <p class="mt-1 max-w-2xl text-gray-500 text-sm dark:text-neutral-400">
93
+ Declare a host and its pinned host key in <code class="font-mono">config/remote.ts</code>, then define the <code class="font-mono">run-remote-command</code> and <code class="font-mono">open-remote-terminal</code> gates in <code class="font-mono">app/Gates.ts</code>. Until then nobody can reach a server from here, which is the safe default.
94
+ </p>
95
+ </section>
96
+
97
+ <section :if="hosts().length > 0" class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
98
+ <div class="px-5 py-3 border-b border-gray-200 dark:border-neutral-700">
99
+ <h2 class="font-semibold text-gray-900 dark:text-white">Hosts</h2>
100
+ </div>
101
+ <ul class="divide-gray-200 divide-y dark:divide-neutral-700">
102
+ <template :for="entry in hosts()">
103
+ <li class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-5 py-3">
104
+ <div>
105
+ <p class="font-medium text-gray-900 dark:text-white">{{ entry.key }}</p>
106
+ <p class="font-mono text-gray-500 text-xs dark:text-neutral-400">{{ entry.user + '@' + entry.host + ':' + entry.port }}</p>
107
+ </div>
108
+ <Button :if="entry.terminal" variant="outline" @click="showTerminal(entry.key)">
109
+ <span class="h-4 w-4 i-hugeicons-command-line"></span>
110
+ Open terminal
111
+ </Button>
112
+ <span :if="!entry.terminal" class="text-gray-400 text-xs dark:text-neutral-500">Named commands only</span>
113
+ </li>
114
+ </template>
115
+ </ul>
116
+ </section>
117
+
118
+ <section :if="terminalHost()" class="overflow-hidden bg-neutral-950 border border-neutral-800 rounded-lg">
119
+ <div class="flex items-center justify-between px-4 py-2 border-b border-neutral-800">
120
+ <p class="font-mono text-neutral-300 text-sm">{{ terminalHost() }}</p>
121
+ <Button variant="ghost" size="sm" @click="hideTerminal()">
122
+ <span class="h-4 w-4 i-hugeicons-cancel-01"></span>
123
+ Close
124
+ </Button>
125
+ </div>
126
+ <div class="p-2 h-[28rem]">
127
+ <stacks-remote-terminal :host="terminalHost()" class="block h-full" @terminal-exit="onTerminalExit"></stacks-remote-terminal>
128
+ </div>
129
+ </section>
130
+
131
+ <section :if="commands().length > 0" class="p-5 space-y-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
132
+ <h2 class="font-semibold text-gray-900 dark:text-white">Run a command</h2>
133
+ <form class="flex flex-col gap-3 sm:flex-row sm:items-end" @submit.prevent="run">
134
+ <label class="flex flex-col gap-1 text-gray-700 text-sm dark:text-neutral-200">
135
+ Host
136
+ <select x-model="selectedHost" class="px-3 py-2 text-sm dark:bg-neutral-700 border border-gray-300 rounded-md dark:border-neutral-600">
137
+ <template :for="entry in hosts()">
138
+ <option :value="entry.key">{{ entry.key }}</option>
139
+ </template>
140
+ </select>
141
+ </label>
142
+ <label class="flex flex-1 flex-col gap-1 text-gray-700 text-sm dark:text-neutral-200">
143
+ Command
144
+ <select x-model="selectedCommand" class="px-3 py-2 text-sm dark:bg-neutral-700 border border-gray-300 rounded-md dark:border-neutral-600">
145
+ <option value="">Choose a command</option>
146
+ <template :for="command in commandsForHost()">
147
+ <option :value="command.key">{{ command.key }} - {{ command.description }}</option>
148
+ </template>
149
+ </select>
150
+ </label>
151
+ <Button type="submit" :disabled="!selectedCommand()" :loading="isRunning()">Run</Button>
152
+ </form>
153
+
154
+ <div :if="result()" class="space-y-2">
155
+ <p class="text-gray-500 text-xs dark:text-neutral-400">
156
+ {{ result()?.command }} on {{ result()?.host }}: {{ result()?.timedOut ? 'timed out' : 'exit ' + result()?.exitCode }} in {{ result()?.durationMs }} ms
157
+ </p>
158
+ <pre :if="result()?.stdout" class="overflow-auto p-3 max-h-80 font-mono text-neutral-100 text-xs bg-neutral-950 rounded-md">{{ result()?.stdout }}</pre>
159
+ <pre :if="result()?.stderr" class="overflow-auto p-3 max-h-80 font-mono text-red-200 text-xs bg-neutral-950 rounded-md">{{ result()?.stderr }}</pre>
160
+ </div>
161
+ </section>
162
+ </div>