@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.
- 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
|
@@ -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
|
+
}
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
59
|
-
"@stacksjs/sanitizer": "^0.2.
|
|
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>
|