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 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,8 @@
1
+ environment:
2
+ NODE_ENV: test
3
+ builds:
4
+ build_1:
5
+ name: Test
6
+ script:
7
+ - npm ci
8
+ - npm test
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ npm version patch
5
+ npm publish
@@ -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
+ })
@@ -0,0 +1,3 @@
1
+ /** @returns {Promise<void>} */
2
+ export default async function configureTesting() {
3
+ }
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
+ }