peakflow-instance-control 0.1.1
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/README.md +33 -0
- package/package.json +23 -0
- package/peak_flow.yml +8 -0
- package/scripts/release-patch.sh +5 -0
- package/scripts/velocious-cli.js +25 -0
- package/spec/keys-parser-spec.js +22 -0
- package/spec/tmux-client-spec.js +48 -0
- package/src/cli-arguments.js +60 -0
- package/src/config/configuration.js +23 -0
- package/src/config/testing.js +3 -0
- package/src/index.js +17 -0
- package/src/keys-parser.js +66 -0
- package/src/terminal-relay.js +87 -0
- package/src/tmux-client.js +133 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# peakflow-instance-control
|
|
2
|
+
|
|
3
|
+
Instance-side terminal relay for Peakflow project environment sessions.
|
|
4
|
+
|
|
5
|
+
The process connects to Peakflow ActionCable, receives terminal input commands, forwards them to tmux, and publishes terminal output snapshots back to Peakflow.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Run tests (Velocious)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Run
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node src/index.js \
|
|
23
|
+
--action-cable-url ws://example.com/cable \
|
|
24
|
+
--instance-control-token TOKEN \
|
|
25
|
+
--project-id PROJECT_ID \
|
|
26
|
+
--project-environment-instance-id INSTANCE_ID \
|
|
27
|
+
--project-environment-terminal-session-id SESSION_ID \
|
|
28
|
+
--tmux-session-name TMUX_SESSION
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Channel contract
|
|
32
|
+
|
|
33
|
+
The process subscribes to `ProjectEnvironmentTerminalSessionsChannel` with `instance_control: true` and handles `request_snapshot`, `send_input`, and `send_keys` command payloads.
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "peakflow-instance-control",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Instance-side terminal relay for Peakflow project environment sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"peakflow-instance-control": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"release:patch": "bash scripts/release-patch.sh",
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"test": "node scripts/velocious-cli.js test spec"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@rails/actioncable": "^8.1.200"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"glob": "^10.5.0",
|
|
19
|
+
"sqlite": "^5.1.1",
|
|
20
|
+
"sqlite3": "^5.1.7",
|
|
21
|
+
"velocious": "^1.0.213"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/peak_flow.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import {glob} from "glob"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Node 20 compatibility shim for Velocious CLI usage of fs.promises.glob.
|
|
8
|
+
* @returns {void}
|
|
9
|
+
*/
|
|
10
|
+
function ensureFsGlob() {
|
|
11
|
+
if (typeof fs.glob == "function") {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fs.glob = async function * fsGlobShim(pattern) {
|
|
16
|
+
const matches = await glob(pattern)
|
|
17
|
+
|
|
18
|
+
for (const match of matches) {
|
|
19
|
+
yield match
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ensureFsGlob()
|
|
25
|
+
await import("velocious/build/bin/velocious.js")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {describe, expect, it} from "velocious/build/src/testing/test.js"
|
|
2
|
+
import {parseKeysChunks} from "../src/keys-parser.js"
|
|
3
|
+
|
|
4
|
+
describe("parseKeysChunks", () => {
|
|
5
|
+
it("parses literals and enter", async () => {
|
|
6
|
+
const chunks = parseKeysChunks("echo hi\r")
|
|
7
|
+
|
|
8
|
+
expect(chunks).toEqual([
|
|
9
|
+
{type: "literal", value: "echo hi"},
|
|
10
|
+
{type: "key", value: "Enter"}
|
|
11
|
+
])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("parses arrow key sequence", async () => {
|
|
15
|
+
const chunks = parseKeysChunks("a\u001b[A")
|
|
16
|
+
|
|
17
|
+
expect(chunks).toEqual([
|
|
18
|
+
{type: "literal", value: "a"},
|
|
19
|
+
{type: "key", value: "Up"}
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {describe, expect, it} from "velocious/build/src/testing/test.js"
|
|
2
|
+
import TmuxClient from "../src/tmux-client.js"
|
|
3
|
+
|
|
4
|
+
describe("TmuxClient", () => {
|
|
5
|
+
it("sends literal and enter chunks", async () => {
|
|
6
|
+
const calledArguments = []
|
|
7
|
+
const tmuxClient = new TmuxClient({
|
|
8
|
+
tmuxSessionName: "session-1",
|
|
9
|
+
executeTmuxCommand: async (argumentsList) => {
|
|
10
|
+
calledArguments.push(argumentsList)
|
|
11
|
+
return ""
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
await tmuxClient.sendKeys("echo hi\r")
|
|
16
|
+
|
|
17
|
+
expect(calledArguments).toEqual([
|
|
18
|
+
["send-keys", "-t", "session-1", "-l", "--", "echo hi"],
|
|
19
|
+
["send-keys", "-t", "session-1", "Enter"]
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("creates tmux session when missing", async () => {
|
|
24
|
+
const calledArguments = []
|
|
25
|
+
let hasSessionAttempts = 0
|
|
26
|
+
const tmuxClient = new TmuxClient({
|
|
27
|
+
tmuxSessionName: "session-2",
|
|
28
|
+
executeTmuxCommand: async (argumentsList) => {
|
|
29
|
+
calledArguments.push(argumentsList)
|
|
30
|
+
|
|
31
|
+
if (argumentsList[0] == "has-session") {
|
|
32
|
+
hasSessionAttempts += 1
|
|
33
|
+
throw new Error("missing")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return ""
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
await tmuxClient.ensureSession()
|
|
41
|
+
|
|
42
|
+
expect(hasSessionAttempts).toEqual(1)
|
|
43
|
+
expect(calledArguments).toEqual([
|
|
44
|
+
["has-session", "-t", "session-2"],
|
|
45
|
+
["new-session", "-d", "-s", "session-2"]
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse command line options for instance-control process.
|
|
3
|
+
* @param {string[]} argv
|
|
4
|
+
* @returns {{
|
|
5
|
+
* actionCableUrl: string,
|
|
6
|
+
* instanceControlToken: string,
|
|
7
|
+
* projectEnvironmentInstanceId: string,
|
|
8
|
+
* projectEnvironmentTerminalSessionId: string,
|
|
9
|
+
* projectId: string,
|
|
10
|
+
* tmuxSessionName: string
|
|
11
|
+
* }}
|
|
12
|
+
*/
|
|
13
|
+
export function parseCliArguments(argv) {
|
|
14
|
+
const args = [...argv]
|
|
15
|
+
const options = {}
|
|
16
|
+
|
|
17
|
+
while (args.length > 0) {
|
|
18
|
+
const argument = args.shift()
|
|
19
|
+
|
|
20
|
+
if (!argument.startsWith("--")) {
|
|
21
|
+
throw new Error(`Unexpected argument: ${argument}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const optionName = argument.replace(/^--/, "")
|
|
25
|
+
const optionValue = args.shift()
|
|
26
|
+
|
|
27
|
+
if (!optionValue || optionValue.startsWith("--")) {
|
|
28
|
+
throw new Error(`Missing value for --${optionName}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
options[optionName] = optionValue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
validateRequiredOption({name: "action-cable-url", options})
|
|
35
|
+
validateRequiredOption({name: "instance-control-token", options})
|
|
36
|
+
validateRequiredOption({name: "project-environment-instance-id", options})
|
|
37
|
+
validateRequiredOption({name: "project-environment-terminal-session-id", options})
|
|
38
|
+
validateRequiredOption({name: "project-id", options})
|
|
39
|
+
validateRequiredOption({name: "tmux-session-name", options})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
actionCableUrl: options["action-cable-url"],
|
|
43
|
+
instanceControlToken: options["instance-control-token"],
|
|
44
|
+
projectEnvironmentInstanceId: options["project-environment-instance-id"],
|
|
45
|
+
projectEnvironmentTerminalSessionId: options["project-environment-terminal-session-id"],
|
|
46
|
+
projectId: options["project-id"],
|
|
47
|
+
tmuxSessionName: options["tmux-session-name"]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate required CLI option.
|
|
53
|
+
* @param {{name: string, options: Record<string, string>}} params
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
function validateRequiredOption({name, options}) {
|
|
57
|
+
if (!options[name]) {
|
|
58
|
+
throw new Error(`Missing required option --${name}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import AsyncTrackedMultiConnection from "velocious/build/src/database/pool/async-tracked-multi-connection.js"
|
|
2
|
+
import Configuration from "velocious/build/src/configuration.js"
|
|
3
|
+
import NodeEnvironmentHandler from "velocious/build/src/environment-handlers/node.js"
|
|
4
|
+
import SqliteDriver from "velocious/build/src/database/drivers/sqlite/index.js"
|
|
5
|
+
|
|
6
|
+
export default new Configuration({
|
|
7
|
+
cookieSecret: "peakflow-instance-control-test-secret",
|
|
8
|
+
database: {
|
|
9
|
+
test: {
|
|
10
|
+
default: {
|
|
11
|
+
database: "/tmp/peakflow-instance-control-test",
|
|
12
|
+
driver: SqliteDriver,
|
|
13
|
+
poolType: AsyncTrackedMultiConnection,
|
|
14
|
+
type: "sqlite",
|
|
15
|
+
name: "peakflow-instance-control"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
environmentHandler: new NodeEnvironmentHandler(),
|
|
20
|
+
locale: () => "en",
|
|
21
|
+
localeFallbacks: {en: ["en"]},
|
|
22
|
+
locales: ["en"]
|
|
23
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {parseCliArguments} from "./cli-arguments.js"
|
|
4
|
+
import TerminalRelay from "./terminal-relay.js"
|
|
5
|
+
|
|
6
|
+
/** @returns {Promise<void>} */
|
|
7
|
+
async function main() {
|
|
8
|
+
const options = parseCliArguments(process.argv.slice(2))
|
|
9
|
+
const relay = new TerminalRelay(options)
|
|
10
|
+
|
|
11
|
+
await relay.start()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
main().catch((error) => {
|
|
15
|
+
console.error(error)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const SPECIAL_CHARACTER_KEYS = {
|
|
2
|
+
"\r": "Enter",
|
|
3
|
+
"\t": "Tab",
|
|
4
|
+
"\u0003": "C-c",
|
|
5
|
+
"\u007F": "BSpace"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SPECIAL_SEQUENCE_KEYS = {
|
|
9
|
+
"\u001b[A": "Up",
|
|
10
|
+
"\u001b[B": "Down",
|
|
11
|
+
"\u001b[C": "Right",
|
|
12
|
+
"\u001b[D": "Left"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse browser key stream into tmux chunks.
|
|
17
|
+
* @param {string} input
|
|
18
|
+
* @returns {{type: "key" | "literal", value: string}[]}
|
|
19
|
+
*/
|
|
20
|
+
export function parseKeysChunks(input) {
|
|
21
|
+
const normalizedInput = String(input || "")
|
|
22
|
+
const chunks = []
|
|
23
|
+
let literalChunk = ""
|
|
24
|
+
let index = 0
|
|
25
|
+
|
|
26
|
+
while (index < normalizedInput.length) {
|
|
27
|
+
const sequence = normalizedInput.slice(index, index + 3)
|
|
28
|
+
const specialSequenceKey = SPECIAL_SEQUENCE_KEYS[sequence]
|
|
29
|
+
|
|
30
|
+
if (specialSequenceKey) {
|
|
31
|
+
appendLiteralChunk({chunks, literalChunk})
|
|
32
|
+
literalChunk = ""
|
|
33
|
+
chunks.push({type: "key", value: specialSequenceKey})
|
|
34
|
+
index += 3
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const character = normalizedInput[index]
|
|
39
|
+
const specialCharacterKey = SPECIAL_CHARACTER_KEYS[character]
|
|
40
|
+
|
|
41
|
+
if (specialCharacterKey) {
|
|
42
|
+
appendLiteralChunk({chunks, literalChunk})
|
|
43
|
+
literalChunk = ""
|
|
44
|
+
chunks.push({type: "key", value: specialCharacterKey})
|
|
45
|
+
} else {
|
|
46
|
+
literalChunk += character
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
index += 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
appendLiteralChunk({chunks, literalChunk})
|
|
53
|
+
|
|
54
|
+
return chunks
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append literal chunk when non-empty.
|
|
59
|
+
* @param {{chunks: {type: "key" | "literal", value: string}[], literalChunk: string}} params
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*/
|
|
62
|
+
function appendLiteralChunk({chunks, literalChunk}) {
|
|
63
|
+
if (literalChunk.length > 0) {
|
|
64
|
+
chunks.push({type: "literal", value: literalChunk})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {createConsumer} from "@rails/actioncable"
|
|
2
|
+
import TmuxClient from "./tmux-client.js"
|
|
3
|
+
|
|
4
|
+
/** Instance-side terminal relay over ActionCable. */
|
|
5
|
+
export default class TerminalRelay {
|
|
6
|
+
/**
|
|
7
|
+
* Build relay with options.
|
|
8
|
+
* @param {{
|
|
9
|
+
* actionCableUrl: string,
|
|
10
|
+
* instanceControlToken: string,
|
|
11
|
+
* projectEnvironmentInstanceId: string,
|
|
12
|
+
* projectEnvironmentTerminalSessionId: string,
|
|
13
|
+
* projectId: string,
|
|
14
|
+
* tmuxSessionName: string
|
|
15
|
+
* }} options
|
|
16
|
+
*/
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options
|
|
19
|
+
this.tmuxClient = new TmuxClient({tmuxSessionName: options.tmuxSessionName})
|
|
20
|
+
this.consumer = createConsumer(options.actionCableUrl)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {Promise<void>} */
|
|
24
|
+
async start() {
|
|
25
|
+
await this.tmuxClient.ensureSession()
|
|
26
|
+
|
|
27
|
+
this.subscription = this.consumer.subscriptions.create(
|
|
28
|
+
{
|
|
29
|
+
channel: "ProjectEnvironmentTerminalSessionsChannel",
|
|
30
|
+
instance_control: true,
|
|
31
|
+
instance_control_token: this.options.instanceControlToken,
|
|
32
|
+
project_environment_instance_id: this.options.projectEnvironmentInstanceId,
|
|
33
|
+
project_environment_terminal_session_id: this.options.projectEnvironmentTerminalSessionId,
|
|
34
|
+
project_id: this.options.projectId
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
connected: () => {
|
|
38
|
+
this.publishSnapshot().catch((error) => {
|
|
39
|
+
console.error(error)
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
received: (data) => {
|
|
43
|
+
this.onMessage(data).catch((error) => {
|
|
44
|
+
console.error(error)
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
rejected: () => {
|
|
48
|
+
throw new Error("ProjectEnvironmentTerminalSessionsChannel subscription rejected")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle command payload from ActionCable command stream.
|
|
56
|
+
* @param {Record<string, unknown>} data
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
async onMessage(data) {
|
|
60
|
+
if (!data || typeof data != "object") {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (data.type == "request_snapshot") {
|
|
65
|
+
await this.publishSnapshot()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (data.type == "send_input") {
|
|
70
|
+
await this.tmuxClient.sendInput(data.input)
|
|
71
|
+
await this.publishSnapshot()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (data.type == "send_keys") {
|
|
76
|
+
await this.tmuxClient.sendKeys(data.keys)
|
|
77
|
+
await this.publishSnapshot()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {Promise<void>} */
|
|
82
|
+
async publishSnapshot() {
|
|
83
|
+
const payload = await this.tmuxClient.captureOutputPayload()
|
|
84
|
+
|
|
85
|
+
this.subscription.perform("publish_output", payload)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {execFile} from "node:child_process"
|
|
2
|
+
import {promisify} from "node:util"
|
|
3
|
+
import {parseKeysChunks} from "./keys-parser.js"
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
/** tmux command relay client. */
|
|
8
|
+
export default class TmuxClient {
|
|
9
|
+
/**
|
|
10
|
+
* Build tmux client.
|
|
11
|
+
* @param {{tmuxSessionName?: string, executeTmuxCommand?: (argumentsList: string[]) => Promise<string>}} [options]
|
|
12
|
+
*/
|
|
13
|
+
constructor({tmuxSessionName, executeTmuxCommand} = {}) {
|
|
14
|
+
this.tmuxSessionName = String(tmuxSessionName || "")
|
|
15
|
+
this.executeTmuxCommand = executeTmuxCommand || defaultExecuteTmuxCommand
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @returns {Promise<void>} */
|
|
19
|
+
async ensureSession() {
|
|
20
|
+
await this.safeExecuteTmuxCommand(["has-session", "-t", this.tmuxSessionName])
|
|
21
|
+
.catch(async () => {
|
|
22
|
+
await this.safeExecuteTmuxCommand(["new-session", "-d", "-s", this.tmuxSessionName])
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Send command string with Enter.
|
|
28
|
+
* @param {string} input
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
async sendInput(input) {
|
|
32
|
+
const normalizedInput = String(input || "").trim()
|
|
33
|
+
|
|
34
|
+
if (normalizedInput == "") {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await this.safeExecuteTmuxCommand([
|
|
39
|
+
"send-keys",
|
|
40
|
+
"-t",
|
|
41
|
+
this.tmuxSessionName,
|
|
42
|
+
"--",
|
|
43
|
+
normalizedInput,
|
|
44
|
+
"Enter"
|
|
45
|
+
])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Send raw key stream from browser terminal.
|
|
50
|
+
* @param {string} keys
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async sendKeys(keys) {
|
|
54
|
+
const normalizedKeys = String(keys || "")
|
|
55
|
+
|
|
56
|
+
if (normalizedKeys == "") {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const chunks = parseKeysChunks(normalizedKeys)
|
|
61
|
+
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
if (chunk.type == "literal") {
|
|
64
|
+
await this.safeExecuteTmuxCommand([
|
|
65
|
+
"send-keys",
|
|
66
|
+
"-t",
|
|
67
|
+
this.tmuxSessionName,
|
|
68
|
+
"-l",
|
|
69
|
+
"--",
|
|
70
|
+
chunk.value
|
|
71
|
+
])
|
|
72
|
+
} else {
|
|
73
|
+
await this.safeExecuteTmuxCommand([
|
|
74
|
+
"send-keys",
|
|
75
|
+
"-t",
|
|
76
|
+
this.tmuxSessionName,
|
|
77
|
+
chunk.value
|
|
78
|
+
])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @returns {Promise<{terminal_cursor_col: null, terminal_cursor_row: null, terminal_output: string}>} */
|
|
84
|
+
async captureOutputPayload() {
|
|
85
|
+
const terminalOutput = await this.safeExecuteTmuxCommand([
|
|
86
|
+
"capture-pane",
|
|
87
|
+
"-pt",
|
|
88
|
+
this.tmuxSessionName,
|
|
89
|
+
"-S",
|
|
90
|
+
"-200"
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
terminal_cursor_col: null,
|
|
95
|
+
terminal_cursor_row: null,
|
|
96
|
+
terminal_output: terminalOutput
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Execute tmux command and return stdout.
|
|
102
|
+
* @param {string[]} argumentsList
|
|
103
|
+
* @returns {Promise<string>}
|
|
104
|
+
*/
|
|
105
|
+
async safeExecuteTmuxCommand(argumentsList) {
|
|
106
|
+
if (this.tmuxSessionName == "") {
|
|
107
|
+
throw new Error("Missing tmux session name")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return this.executeTmuxCommand(argumentsList)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Execute a tmux command with subprocess API.
|
|
116
|
+
* @param {string[]} argumentsList
|
|
117
|
+
* @returns {Promise<string>}
|
|
118
|
+
*/
|
|
119
|
+
async function defaultExecuteTmuxCommand(argumentsList) {
|
|
120
|
+
try {
|
|
121
|
+
const response = await execFileAsync("tmux", argumentsList)
|
|
122
|
+
|
|
123
|
+
return String(response.stdout || "")
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const stderr = String(error.stderr || "")
|
|
126
|
+
const stdout = String(error.stdout || "")
|
|
127
|
+
const exitCode = error.code
|
|
128
|
+
|
|
129
|
+
throw new Error(
|
|
130
|
+
`tmux command failed exitCode=${exitCode} args=${JSON.stringify(argumentsList)} stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)}`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|