Skip to content

Commit 67b1763

Browse files
committed
disco run uses websocket shell endpoint if available
1 parent 6cab83f commit 67b1763

4 files changed

Lines changed: 229 additions & 119 deletions

File tree

src/commands/run.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Args, Command, Flags} from '@oclif/core'
22

33
import {getDisco} from '../config.js'
44
import {request, readEventSource} from '../auth-request.js'
5+
import {checkShellSupport, runCommandViaShell} from '../shell-client.js'
56

67
interface RunResponse {
78
run: {
@@ -29,21 +30,43 @@ export default class Run extends Command {
2930
const {args, flags} = await this.parse(Run)
3031

3132
const discoConfig = getDisco(flags.disco || null)
32-
const url = `https://${discoConfig.host}/api/projects/${flags.project}/runs`
33-
const body = {
34-
command: args.command,
35-
service: flags.service ?? null,
36-
timeout: flags.timeout,
33+
34+
// Check if server supports shell (version >= 0.28.0)
35+
const {supported: shellSupported} = await checkShellSupport(discoConfig)
36+
37+
if (shellSupported && args.command) {
38+
// Use websocket shell for running commands
39+
try {
40+
const result = await runCommandViaShell({
41+
project: flags.project,
42+
discoConfig,
43+
service: flags.service,
44+
command: args.command,
45+
})
46+
if (result.exitCode !== 0) {
47+
this.exit(result.exitCode)
48+
}
49+
} catch (error) {
50+
this.error((error as Error).message)
51+
}
52+
} else {
53+
// Fall back to legacy run endpoint
54+
const url = `https://${discoConfig.host}/api/projects/${flags.project}/runs`
55+
const body = {
56+
command: args.command,
57+
service: flags.service ?? null,
58+
timeout: flags.timeout,
59+
}
60+
const res = await request({method: 'POST', url, discoConfig, body, expectedStatuses: [202]})
61+
const data = (await res.json()) as RunResponse
62+
63+
const outputUrl = `https://${discoConfig.host}/api/projects/${flags.project}/runs/${data.run.number}/output`
64+
await readEventSource(outputUrl, discoConfig, {
65+
onMessage(event: MessageEvent) {
66+
const message = JSON.parse(event.data)
67+
process.stdout.write(message.text)
68+
},
69+
})
3770
}
38-
const res = await request({method: 'POST', url, discoConfig, body, expectedStatuses: [202]})
39-
const data = (await res.json()) as RunResponse
40-
41-
const outputUrl = `https://${discoConfig.host}/api/projects/${flags.project}/runs/${data.run.number}/output`
42-
readEventSource(outputUrl, discoConfig, {
43-
onMessage(event: MessageEvent) {
44-
const message = JSON.parse(event.data)
45-
process.stdout.write(message.text)
46-
},
47-
})
4871
}
4972
}

src/commands/shell.ts

Lines changed: 14 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
11
import { Command, Flags } from '@oclif/core'
2-
import { compare } from 'compare-versions'
3-
import WS from 'ws'
42

5-
import { request } from '../auth-request.js'
63
import { getDisco } from '../config.js'
7-
8-
function restoreTerminal(): void {
9-
if (process.stdin.isTTY) {
10-
process.stdin.setRawMode(false)
11-
}
12-
13-
process.stdin.pause()
14-
}
4+
import { checkShellSupport, runInteractiveShell } from '../shell-client.js'
155

166
export default class Shell extends Command {
177

@@ -34,102 +24,23 @@ export default class Shell extends Command {
3424
const discoConfig = getDisco(flags.disco || null)
3525

3626
// Check daemon version supports shell
37-
const metaUrl = `https://${discoConfig.host}/api/disco/meta`
38-
const res = await request({ method: 'GET', url: metaUrl, discoConfig })
39-
const meta = (await res.json()) as { version: string }
27+
const { supported, version } = await checkShellSupport(discoConfig)
4028

41-
if (compare(meta.version, '0.28.0', '<')) {
29+
if (!supported) {
4230
this.error(
43-
'Interactive shell is not available for this version of Disco. Please upgrade using `disco meta:upgrade`',
31+
`Interactive shell is not available for this version of Disco (${version}). Please upgrade using \`disco meta:upgrade\``,
4432
)
4533
}
4634

47-
// Connect directly to WebSocket endpoint
48-
const wsUrl = `wss://${discoConfig.host}/api/projects/${flags.project}/shell`
49-
50-
const ws = new WS(wsUrl)
51-
52-
ws.on('open', () => {
53-
// Send API key and optional service as first message for authentication
54-
const authMessage: { token: string; service?: string } = { token: discoConfig.apiKey }
55-
if (flags.service) {
56-
authMessage.service = flags.service
57-
}
58-
59-
ws.send(JSON.stringify(authMessage))
60-
})
61-
62-
ws.on('message', (data: WS.RawData, isBinary: boolean) => {
63-
if (isBinary) {
64-
// Binary data = terminal output
65-
process.stdout.write(data as Buffer)
66-
} else {
67-
// Text data = JSON control message
68-
try {
69-
const message = JSON.parse(data.toString())
70-
if (message.type === 'connected') {
71-
// Successfully authenticated, set up terminal
72-
if (process.stdin.isTTY) {
73-
process.stdin.setRawMode(true)
74-
}
75-
76-
process.stdin.resume()
77-
78-
// Send initial terminal size
79-
if (process.stdout.isTTY) {
80-
ws.send(JSON.stringify({
81-
type: 'resize',
82-
cols: process.stdout.columns,
83-
rows: process.stdout.rows,
84-
}))
85-
}
86-
87-
// Handle terminal resize
88-
process.stdout.on('resize', () => {
89-
if (process.stdout.isTTY && ws.readyState === WS.OPEN) {
90-
ws.send(JSON.stringify({
91-
type: 'resize',
92-
cols: process.stdout.columns,
93-
rows: process.stdout.rows,
94-
}))
95-
}
96-
})
97-
98-
// Forward stdin to WebSocket as binary
99-
process.stdin.on('data', (chunk: Buffer) => {
100-
if (ws.readyState === WS.OPEN) {
101-
ws.send(chunk)
102-
}
103-
})
104-
} else if (message.type === 'ping') {
105-
// Respond to server heartbeat
106-
if (ws.readyState === WS.OPEN) {
107-
ws.send(JSON.stringify({ type: 'pong' }))
108-
}
109-
}
110-
} catch {
111-
// Not JSON, treat as text output
112-
process.stdout.write(data.toString())
113-
}
114-
}
115-
})
116-
117-
ws.on('close', (code, reason) => {
118-
restoreTerminal()
119-
120-
if (code !== 1000) {
121-
this.error(`Connection closed: ${code} ${reason.toString()}`)
122-
}
123-
})
124-
125-
ws.on('error', (err) => {
126-
restoreTerminal()
127-
this.error(`WebSocket error: ${err.message}`)
128-
})
129-
130-
// Keep the process running until WebSocket closes
131-
await new Promise<void>((resolve) => {
132-
ws.on('close', () => resolve())
133-
})
35+
try {
36+
await runInteractiveShell({
37+
project: flags.project,
38+
discoConfig,
39+
service: flags.service,
40+
interactive: true,
41+
})
42+
} catch (error) {
43+
this.error((error as Error).message)
44+
}
13445
}
13546
}

src/shell-client.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { compare } from 'compare-versions'
2+
import WS from 'ws'
3+
4+
import { request } from './auth-request.js'
5+
import { DiscoConfig } from './config.js'
6+
7+
export interface ShellOptions {
8+
project: string
9+
discoConfig: DiscoConfig
10+
service?: string
11+
command?: string
12+
interactive?: boolean
13+
}
14+
15+
export interface ShellResult {
16+
exitCode: number
17+
output: string
18+
}
19+
20+
export async function checkShellSupport(discoConfig: DiscoConfig): Promise<{ supported: boolean; version: string }> {
21+
const metaUrl = `https://${discoConfig.host}/api/disco/meta`
22+
const res = await request({ method: 'GET', url: metaUrl, discoConfig })
23+
const meta = (await res.json()) as { version: string }
24+
return {
25+
supported: compare(meta.version, '0.28.0', '>='),
26+
version: meta.version,
27+
}
28+
}
29+
30+
export function runCommandViaShell(options: ShellOptions): Promise<ShellResult> {
31+
const { project, discoConfig, service, command } = options
32+
33+
return new Promise((resolve, reject) => {
34+
const wsUrl = `wss://${discoConfig.host}/api/projects/${project}/shell`
35+
const ws = new WS(wsUrl)
36+
let output = ''
37+
let exitCode = 0
38+
39+
ws.on('open', () => {
40+
const authMessage: { token: string; service?: string; command?: string } = { token: discoConfig.apiKey }
41+
42+
if (service) {
43+
authMessage.service = service
44+
}
45+
46+
if (command) {
47+
authMessage.command = command
48+
}
49+
50+
ws.send(JSON.stringify(authMessage))
51+
})
52+
53+
ws.on('message', (data: WS.RawData, isBinary: boolean) => {
54+
if (isBinary) {
55+
const chunk = (data as Buffer).toString()
56+
output += chunk
57+
process.stdout.write(chunk)
58+
} else {
59+
try {
60+
const message = JSON.parse(data.toString())
61+
if (message.type === 'connected') {
62+
// Successfully authenticated - command mode doesn't need raw terminal
63+
} else if (message.type === 'ping' && ws.readyState === WS.OPEN) {
64+
ws.send(JSON.stringify({ type: 'pong' }))
65+
} else if (message.type === 'exit') {
66+
exitCode = message.code ?? 0
67+
}
68+
} catch {
69+
// Not JSON, treat as text output
70+
const text = data.toString()
71+
output += text
72+
process.stdout.write(text)
73+
}
74+
}
75+
})
76+
77+
ws.on('close', (code, reason) => {
78+
if (code === 1000) {
79+
resolve({ exitCode, output })
80+
} else {
81+
reject(new Error(`Connection closed: ${code} ${reason.toString()}`))
82+
}
83+
})
84+
85+
ws.on('error', (err) => {
86+
reject(new Error(`WebSocket error: ${err.message}`))
87+
})
88+
})
89+
}
90+
91+
function restoreTerminal(): void {
92+
if (process.stdin.isTTY) {
93+
process.stdin.setRawMode(false)
94+
}
95+
96+
process.stdin.pause()
97+
}
98+
99+
export function runInteractiveShell(options: ShellOptions): Promise<void> {
100+
const { project, discoConfig, service } = options
101+
102+
return new Promise((resolve, reject) => {
103+
const wsUrl = `wss://${discoConfig.host}/api/projects/${project}/shell`
104+
const ws = new WS(wsUrl)
105+
106+
ws.on('open', () => {
107+
const authMessage: { token: string; service?: string } = { token: discoConfig.apiKey }
108+
109+
if (service) {
110+
authMessage.service = service
111+
}
112+
113+
ws.send(JSON.stringify(authMessage))
114+
})
115+
116+
ws.on('message', (data: WS.RawData, isBinary: boolean) => {
117+
if (isBinary) {
118+
process.stdout.write(data as Buffer)
119+
} else {
120+
try {
121+
const message = JSON.parse(data.toString())
122+
if (message.type === 'connected') {
123+
if (process.stdin.isTTY) {
124+
process.stdin.setRawMode(true)
125+
}
126+
127+
process.stdin.resume()
128+
129+
if (process.stdout.isTTY) {
130+
ws.send(JSON.stringify({
131+
type: 'resize',
132+
cols: process.stdout.columns,
133+
rows: process.stdout.rows,
134+
}))
135+
}
136+
137+
process.stdout.on('resize', () => {
138+
if (process.stdout.isTTY && ws.readyState === WS.OPEN) {
139+
ws.send(JSON.stringify({
140+
type: 'resize',
141+
cols: process.stdout.columns,
142+
rows: process.stdout.rows,
143+
}))
144+
}
145+
})
146+
147+
process.stdin.on('data', (chunk: Buffer) => {
148+
if (ws.readyState === WS.OPEN) {
149+
ws.send(chunk)
150+
}
151+
})
152+
} else if (message.type === 'ping' && ws.readyState === WS.OPEN) {
153+
ws.send(JSON.stringify({ type: 'pong' }))
154+
}
155+
} catch {
156+
process.stdout.write(data.toString())
157+
}
158+
}
159+
})
160+
161+
ws.on('close', (code, reason) => {
162+
restoreTerminal()
163+
164+
if (code === 1000) {
165+
resolve()
166+
} else {
167+
reject(new Error(`Connection closed: ${code} ${reason.toString()}`))
168+
}
169+
})
170+
171+
ws.on('error', (err) => {
172+
restoreTerminal()
173+
reject(new Error(`WebSocket error: ${err.message}`))
174+
})
175+
})
176+
}

tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./src/auth-request.ts","./src/config.ts","./src/index.ts","./src/commands/deploy.ts","./src/commands/init.ts","./src/commands/logs.ts","./src/commands/run.ts","./src/commands/shell.ts","./src/commands/apikeys/list.ts","./src/commands/apikeys/remove.ts","./src/commands/deploy/cancel.ts","./src/commands/deploy/list.ts","./src/commands/deploy/output.ts","./src/commands/discos/list.ts","./src/commands/domains/add.ts","./src/commands/domains/list.ts","./src/commands/domains/remove.ts","./src/commands/env/get.ts","./src/commands/env/list.ts","./src/commands/env/remove.ts","./src/commands/env/set.ts","./src/commands/github/apps/add.ts","./src/commands/github/apps/list.ts","./src/commands/github/apps/manage.ts","./src/commands/github/apps/prune.ts","./src/commands/github/repos/list.ts","./src/commands/invite/accept.ts","./src/commands/invite/create.ts","./src/commands/meta/host.ts","./src/commands/meta/info.ts","./src/commands/meta/stats.ts","./src/commands/meta/upgrade.ts","./src/commands/nodes/add.ts","./src/commands/nodes/list.ts","./src/commands/nodes/remove.ts","./src/commands/postgres/create.ts","./src/commands/postgres/tunnel.ts","./src/commands/postgres/addon/install.ts","./src/commands/postgres/addon/remove.ts","./src/commands/postgres/addon/update.ts","./src/commands/postgres/databases/add.ts","./src/commands/postgres/databases/attach.ts","./src/commands/postgres/databases/detach.ts","./src/commands/postgres/databases/list.ts","./src/commands/postgres/databases/remove.ts","./src/commands/postgres/instances/add.ts","./src/commands/postgres/instances/list.ts","./src/commands/postgres/instances/remove.ts","./src/commands/projects/add.ts","./src/commands/projects/list.ts","./src/commands/projects/move.ts","./src/commands/projects/remove.ts","./src/commands/registry/addon/install.ts","./src/commands/registry/addon/remove.ts","./src/commands/registry/addon/update.ts","./src/commands/scale/get.ts","./src/commands/scale/set.ts","./src/commands/syslog/add.ts","./src/commands/syslog/list.ts","./src/commands/syslog/remove.ts","./src/commands/volumes/export.ts","./src/commands/volumes/import.ts","./src/commands/volumes/list.ts"],"version":"5.8.3"}
1+
{"root":["./src/auth-request.ts","./src/config.ts","./src/index.ts","./src/shell-client.ts","./src/commands/deploy.ts","./src/commands/init.ts","./src/commands/logs.ts","./src/commands/run.ts","./src/commands/shell.ts","./src/commands/apikeys/list.ts","./src/commands/apikeys/remove.ts","./src/commands/deploy/cancel.ts","./src/commands/deploy/list.ts","./src/commands/deploy/output.ts","./src/commands/discos/list.ts","./src/commands/domains/add.ts","./src/commands/domains/list.ts","./src/commands/domains/remove.ts","./src/commands/env/get.ts","./src/commands/env/list.ts","./src/commands/env/remove.ts","./src/commands/env/set.ts","./src/commands/github/apps/add.ts","./src/commands/github/apps/list.ts","./src/commands/github/apps/manage.ts","./src/commands/github/apps/prune.ts","./src/commands/github/repos/list.ts","./src/commands/invite/accept.ts","./src/commands/invite/create.ts","./src/commands/meta/host.ts","./src/commands/meta/info.ts","./src/commands/meta/stats.ts","./src/commands/meta/upgrade.ts","./src/commands/nodes/add.ts","./src/commands/nodes/list.ts","./src/commands/nodes/remove.ts","./src/commands/postgres/create.ts","./src/commands/postgres/tunnel.ts","./src/commands/postgres/addon/install.ts","./src/commands/postgres/addon/remove.ts","./src/commands/postgres/addon/update.ts","./src/commands/postgres/databases/add.ts","./src/commands/postgres/databases/attach.ts","./src/commands/postgres/databases/detach.ts","./src/commands/postgres/databases/list.ts","./src/commands/postgres/databases/remove.ts","./src/commands/postgres/instances/add.ts","./src/commands/postgres/instances/list.ts","./src/commands/postgres/instances/remove.ts","./src/commands/projects/add.ts","./src/commands/projects/list.ts","./src/commands/projects/move.ts","./src/commands/projects/remove.ts","./src/commands/registry/addon/install.ts","./src/commands/registry/addon/remove.ts","./src/commands/registry/addon/update.ts","./src/commands/scale/get.ts","./src/commands/scale/set.ts","./src/commands/syslog/add.ts","./src/commands/syslog/list.ts","./src/commands/syslog/remove.ts","./src/commands/volumes/export.ts","./src/commands/volumes/import.ts","./src/commands/volumes/list.ts"],"version":"5.8.3"}

0 commit comments

Comments
 (0)