jbs-client 0.0.1 → 0.0.5
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 +9 -56
- package/bin/jbs-client.js +71 -0
- package/package.json +16 -16
- package/.github/workflows/release.yml +0 -54
- package/build.ts +0 -162
- package/bun.lock +0 -234
- package/scripts/npm/README.md +0 -14
- package/scripts/npm/bin/jbs-client +0 -70
- package/src/app.tsx +0 -239
- package/src/components/action-menu.tsx +0 -28
- package/src/components/log-panel.tsx +0 -31
- package/src/components/param-form.tsx +0 -73
- package/src/components.test.tsx +0 -36
- package/src/index.tsx +0 -19
- package/src/protocol.test.ts +0 -40
- package/src/protocol.ts +0 -201
- package/src/state.test.ts +0 -31
- package/src/state.ts +0 -77
- package/src/types.ts +0 -34
- package/src/ws.ts +0 -63
- package/tsconfig.json +0 -15
- /package/{scripts/npm/postinstall.mjs → postinstall.mjs} +0 -0
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react"
|
|
2
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
3
|
-
|
|
4
|
-
type LogPanelProps = {
|
|
5
|
-
logs: string[]
|
|
6
|
-
focused?: boolean
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function LogPanel({ logs, focused = false }: LogPanelProps) {
|
|
10
|
-
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
11
|
-
|
|
12
|
-
useEffect(() => {
|
|
13
|
-
if (!scrollRef.current) {
|
|
14
|
-
return
|
|
15
|
-
}
|
|
16
|
-
scrollRef.current.scrollTo({ x: 0, y: scrollRef.current.scrollHeight })
|
|
17
|
-
}, [logs])
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<box border borderStyle="rounded" borderColor="#26f7ce" padding={1} flexDirection="column" flexGrow={1} title="Logs">
|
|
21
|
-
<scrollbox ref={scrollRef} flexGrow={1} focused={focused} stickyScroll stickyStart="bottom">
|
|
22
|
-
<box flexDirection="column">
|
|
23
|
-
{logs.length === 0 ? <text fg="#6b7280" selectable>Waiting for logs...</text> : null}
|
|
24
|
-
{logs.map((log, index) => (
|
|
25
|
-
<text key={`${index}-${log}`} selectable>{log}</text>
|
|
26
|
-
))}
|
|
27
|
-
</box>
|
|
28
|
-
</scrollbox>
|
|
29
|
-
</box>
|
|
30
|
-
)
|
|
31
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { useRef } from "react"
|
|
2
|
-
import { menu } from "../protocol.js"
|
|
3
|
-
|
|
4
|
-
type ParamFormProps = {
|
|
5
|
-
actionIndex: number
|
|
6
|
-
values: string[]
|
|
7
|
-
focusIndex: number
|
|
8
|
-
focused: boolean
|
|
9
|
-
validationError: string | null
|
|
10
|
-
onChange: (index: number, value: string) => void
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function ParamForm({ actionIndex, values, focusIndex, focused, validationError, onChange }: ParamFormProps) {
|
|
14
|
-
const action = menu[actionIndex]
|
|
15
|
-
const submitFocused = focused && focusIndex === action.params.length
|
|
16
|
-
const textareaRefs = useRef<Record<number, { plainText?: string; initialValue?: string } | null>>({})
|
|
17
|
-
|
|
18
|
-
return (
|
|
19
|
-
<box border borderStyle="rounded" borderColor="#d946ef" padding={1} flexDirection="column" gap={1} flexGrow={1} title={action.name}>
|
|
20
|
-
{action.params.length === 0 ? <text fg="#d1d5db">This action does not need parameters.</text> : null}
|
|
21
|
-
{action.params.map((param, index) => (
|
|
22
|
-
<box key={`${action.name}-${param.name}`} flexDirection="column" gap={1}>
|
|
23
|
-
<text fg="#a7f3d0">{param.name}</text>
|
|
24
|
-
{param.inputType === "textarea" ? (
|
|
25
|
-
<line-number minWidth={4} paddingRight={1} fg="#f472b6">
|
|
26
|
-
<textarea
|
|
27
|
-
ref={(instance) => {
|
|
28
|
-
textareaRefs.current[index] = instance
|
|
29
|
-
}}
|
|
30
|
-
initialValue={values[index] ?? ""}
|
|
31
|
-
height={12}
|
|
32
|
-
width="100%"
|
|
33
|
-
focused={focused && focusIndex === index}
|
|
34
|
-
wrapMode="word"
|
|
35
|
-
onContentChange={() => onChange(index, textareaRefs.current[index]?.plainText ?? values[index] ?? "")}
|
|
36
|
-
/>
|
|
37
|
-
</line-number>
|
|
38
|
-
) : param.inputType === "select" ? (
|
|
39
|
-
<select
|
|
40
|
-
options={(param.options ?? []).map((option) => ({
|
|
41
|
-
name: option.name,
|
|
42
|
-
description: option.description,
|
|
43
|
-
value: option.value
|
|
44
|
-
}))}
|
|
45
|
-
selectedIndex={Math.max(0, (param.options ?? []).findIndex((option) => option.value === (values[index] ?? param.value)))}
|
|
46
|
-
focused={focused && focusIndex === index}
|
|
47
|
-
height={4}
|
|
48
|
-
onChange={(selectedIndex, option) => onChange(index, String(option?.value ?? (param.options ?? [])[selectedIndex]?.value ?? param.value))}
|
|
49
|
-
/>
|
|
50
|
-
) : (
|
|
51
|
-
<input
|
|
52
|
-
value={values[index] ?? ""}
|
|
53
|
-
onChange={(value) => onChange(index, value)}
|
|
54
|
-
width="100%"
|
|
55
|
-
focused={focused && focusIndex === index}
|
|
56
|
-
/>
|
|
57
|
-
)}
|
|
58
|
-
</box>
|
|
59
|
-
))}
|
|
60
|
-
<box
|
|
61
|
-
border
|
|
62
|
-
borderStyle={submitFocused ? "double" : "single"}
|
|
63
|
-
borderColor={submitFocused ? "#22c55e" : "#6b7280"}
|
|
64
|
-
paddingX={2}
|
|
65
|
-
paddingY={1}
|
|
66
|
-
>
|
|
67
|
-
<text fg={submitFocused ? "#22c55e" : "#9ca3af"}>[ Submit ]</text>
|
|
68
|
-
</box>
|
|
69
|
-
{validationError ? <text fg="#f87171">{validationError}</text> : null}
|
|
70
|
-
<text fg="#9ca3af">Tab or Shift+Tab changes form focus. Esc returns to the action menu.</text>
|
|
71
|
-
</box>
|
|
72
|
-
)
|
|
73
|
-
}
|
package/src/components.test.tsx
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { testRender } from "@opentui/react/test-utils"
|
|
3
|
-
import { ActionMenu } from "./components/action-menu.js"
|
|
4
|
-
import { LogPanel } from "./components/log-panel.js"
|
|
5
|
-
|
|
6
|
-
let setup: Awaited<ReturnType<typeof testRender>> | undefined
|
|
7
|
-
|
|
8
|
-
afterEach(() => {
|
|
9
|
-
setup?.renderer.destroy()
|
|
10
|
-
setup = undefined
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
describe("components", () => {
|
|
14
|
-
test("ActionMenu renders menu title and options", async () => {
|
|
15
|
-
setup = await testRender(<ActionMenu focused selectedIndex={0} onChange={() => {}} onSelect={() => {}} />, {
|
|
16
|
-
width: 70,
|
|
17
|
-
height: 20
|
|
18
|
-
})
|
|
19
|
-
await setup.renderOnce()
|
|
20
|
-
const frame = setup.captureCharFrame()
|
|
21
|
-
expect(frame).toContain("Input the action?")
|
|
22
|
-
expect(frame).toContain("Watch")
|
|
23
|
-
expect(frame).toContain("Eval")
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test("LogPanel renders logs and status", async () => {
|
|
27
|
-
setup = await testRender(<LogPanel logs={["hello", "world"]} />, {
|
|
28
|
-
width: 60,
|
|
29
|
-
height: 16
|
|
30
|
-
})
|
|
31
|
-
await setup.renderOnce()
|
|
32
|
-
const frame = setup.captureCharFrame()
|
|
33
|
-
expect(frame).toContain("Logs")
|
|
34
|
-
expect(frame).toContain("world")
|
|
35
|
-
})
|
|
36
|
-
})
|
package/src/index.tsx
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { createCliRenderer } from "@opentui/core"
|
|
2
|
-
import { createRoot } from "@opentui/react"
|
|
3
|
-
import { App } from "./app.js"
|
|
4
|
-
|
|
5
|
-
function readArg(name: string, fallback: string): string {
|
|
6
|
-
const args = Bun.argv.slice(2)
|
|
7
|
-
const index = args.indexOf(name)
|
|
8
|
-
return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const host = readArg("--host", "localhost")
|
|
12
|
-
const wsPort = Number.parseInt(readArg("--ws_port", "18000"), 10)
|
|
13
|
-
const connectBackend = readArg("--connect", "true") !== "false"
|
|
14
|
-
|
|
15
|
-
const renderer = await createCliRenderer({
|
|
16
|
-
exitOnCtrlC: false
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
createRoot(renderer).render(<App host={host} wsPort={wsPort} connectBackend={connectBackend} />)
|
package/src/protocol.test.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { buildInitialFormValues, buildPayload, menu, validateAction } from "./protocol.js"
|
|
3
|
-
|
|
4
|
-
describe("protocol", () => {
|
|
5
|
-
test("buildInitialFormValues copies defaults", () => {
|
|
6
|
-
expect(buildInitialFormValues(0)).toEqual(["", "0"])
|
|
7
|
-
expect(buildInitialFormValues(7)[0]).toContain("ApplicationContext")
|
|
8
|
-
expect(buildInitialFormValues(3)[2]).toBe("asm")
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
test("validateAction enforces signature checks", () => {
|
|
12
|
-
expect(validateAction(0, ["com.demo.Service#run", "1"])).toBe(true)
|
|
13
|
-
expect(validateAction(0, ["com.demo.Service.run", "1"])).toBe(false)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test("buildPayload serializes change body request", () => {
|
|
17
|
-
const payload = JSON.parse(buildPayload(3, ["a.B#c", "java.lang.String, int", "javassist", "return 1;"])) as Record<string, unknown>
|
|
18
|
-
expect(payload.type).toBe("CHANGE_BODY")
|
|
19
|
-
expect(payload.className).toBe("a.B")
|
|
20
|
-
expect(payload.method).toBe("c")
|
|
21
|
-
expect(payload.paramTypes).toEqual(["java.lang.String", "int"])
|
|
22
|
-
expect(payload.body).toBe("return 1;")
|
|
23
|
-
expect(payload.mode).toBe(0)
|
|
24
|
-
expect(typeof payload.id).toBe("string")
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test("buildPayload serializes eval and decompile requests", () => {
|
|
28
|
-
const decompilePayload = JSON.parse(buildPayload(5, ["demo.Service"])) as Record<string, unknown>
|
|
29
|
-
expect(decompilePayload.type).toBe("DECOMPILE")
|
|
30
|
-
expect(decompilePayload.className).toBe("demo.Service")
|
|
31
|
-
|
|
32
|
-
const evalPayload = JSON.parse(buildPayload(6, ["1 + 1"])) as Record<string, unknown>
|
|
33
|
-
expect(evalPayload.type).toBe("EVAL")
|
|
34
|
-
expect(evalPayload.body).toBe("1 + 1")
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test("menu preserves expected action count", () => {
|
|
38
|
-
expect(menu.map((item) => item.name)).toEqual(["Watch", "OuterWatch", "Trace", "ChangeBody", "ChangeResult", "Decompile", "Eval", "Exec", "Reset"])
|
|
39
|
-
})
|
|
40
|
-
})
|
package/src/protocol.ts
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import type { FunctionDefinition } from "./types.js"
|
|
2
|
-
|
|
3
|
-
const engineOptions = [
|
|
4
|
-
{ name: "ASM", description: "Default bytecode engine", value: "asm" },
|
|
5
|
-
{ name: "Javassist", description: "Source-level transformer", value: "javassist" }
|
|
6
|
-
]
|
|
7
|
-
|
|
8
|
-
function classAndMethodChecker(value: string): boolean {
|
|
9
|
-
return value.split("#").length === 2
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function nonEmptyChecker(value: string): boolean {
|
|
13
|
-
return value.trim().length > 0
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function engineToMode(value: string): number {
|
|
17
|
-
return value === "javassist" ? 0 : 1
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function randomString(length: number): string {
|
|
21
|
-
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
22
|
-
let result = ""
|
|
23
|
-
for (let index = 0; index < length; index += 1) {
|
|
24
|
-
result += alphabet[Math.floor(Math.random() * alphabet.length)]
|
|
25
|
-
}
|
|
26
|
-
return result
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function commonMap(): Record<string, unknown> {
|
|
30
|
-
return {
|
|
31
|
-
id: randomString(4),
|
|
32
|
-
timestamp: Date.now()
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function parseParamTypes(value: string): string[] {
|
|
37
|
-
return value
|
|
38
|
-
.split(",")
|
|
39
|
-
.map((part) => part.trim())
|
|
40
|
-
.filter((part) => part.length > 0)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function splitSignature(value: string): [string, string] {
|
|
44
|
-
const [className = "", method = ""] = value.split("#")
|
|
45
|
-
return [className, method]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const menu: FunctionDefinition[] = [
|
|
49
|
-
{
|
|
50
|
-
name: "Watch",
|
|
51
|
-
params: [
|
|
52
|
-
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
53
|
-
{ name: "MinCost", inputType: "text", checker: () => true, value: "0" }
|
|
54
|
-
],
|
|
55
|
-
toPayload: (params: string[]) => JSON.stringify({ ...commonMap(), type: "WATCH", signature: params[0], minCost: Number.parseInt(params[1] || "0", 10) || 0 })
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: "OuterWatch",
|
|
59
|
-
params: [
|
|
60
|
-
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
61
|
-
{ name: "InnerClassName#InnerMethodName", inputType: "text", checker: classAndMethodChecker, value: "" }
|
|
62
|
-
],
|
|
63
|
-
toPayload: (params: string[]) => JSON.stringify({ ...commonMap(), type: "OUTER_WATCH", signature: params[0], innerSignature: params[1] })
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: "Trace",
|
|
67
|
-
params: [
|
|
68
|
-
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
69
|
-
{ name: "MinCost", inputType: "text", checker: () => true, value: "0" },
|
|
70
|
-
{ name: "IgnoreSubMethodZeroCost", inputType: "text", checker: () => true, value: "true" }
|
|
71
|
-
],
|
|
72
|
-
toPayload: (params: string[]) => JSON.stringify({
|
|
73
|
-
...commonMap(),
|
|
74
|
-
type: "TRACE",
|
|
75
|
-
signature: params[0],
|
|
76
|
-
minCost: Number.parseInt(params[1] || "0", 10) || 0,
|
|
77
|
-
ignoreZero: params[2] === "true"
|
|
78
|
-
})
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
name: "ChangeBody",
|
|
82
|
-
params: [
|
|
83
|
-
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
84
|
-
{ name: "ParamTypes", inputType: "text", checker: () => true, value: "" },
|
|
85
|
-
{ name: "Engine", inputType: "select", checker: () => true, value: "asm", options: engineOptions },
|
|
86
|
-
{ name: "Body", inputType: "textarea", checker: () => true, value: "" }
|
|
87
|
-
],
|
|
88
|
-
toPayload: (params: string[]) => {
|
|
89
|
-
const [className, method] = splitSignature(params[0])
|
|
90
|
-
return JSON.stringify({
|
|
91
|
-
...commonMap(),
|
|
92
|
-
type: "CHANGE_BODY",
|
|
93
|
-
className,
|
|
94
|
-
method,
|
|
95
|
-
paramTypes: parseParamTypes(params[1]),
|
|
96
|
-
body: params[3],
|
|
97
|
-
mode: engineToMode(params[2])
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
name: "ChangeResult",
|
|
103
|
-
params: [
|
|
104
|
-
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
105
|
-
{ name: "ParamTypes", inputType: "text", checker: () => true, value: "" },
|
|
106
|
-
{ name: "InnerClassName#InnerMethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
107
|
-
{ name: "Engine", inputType: "select", checker: () => true, value: "asm", options: engineOptions },
|
|
108
|
-
{ name: "Body", inputType: "textarea", checker: () => true, value: "" }
|
|
109
|
-
],
|
|
110
|
-
toPayload: (params: string[]) => {
|
|
111
|
-
const [className, method] = splitSignature(params[0])
|
|
112
|
-
const [innerClassName, innerMethod] = splitSignature(params[2])
|
|
113
|
-
return JSON.stringify({
|
|
114
|
-
...commonMap(),
|
|
115
|
-
type: "CHANGE_RESULT",
|
|
116
|
-
className,
|
|
117
|
-
method,
|
|
118
|
-
paramTypes: parseParamTypes(params[1]),
|
|
119
|
-
innerClassName,
|
|
120
|
-
innerMethod,
|
|
121
|
-
body: params[4],
|
|
122
|
-
mode: engineToMode(params[3])
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
name: "Decompile",
|
|
128
|
-
params: [
|
|
129
|
-
{ name: "ClassName", inputType: "text", checker: nonEmptyChecker, value: "" }
|
|
130
|
-
],
|
|
131
|
-
toPayload: (params: string[]) => JSON.stringify({
|
|
132
|
-
...commonMap(),
|
|
133
|
-
type: "DECOMPILE",
|
|
134
|
-
className: params[0]
|
|
135
|
-
})
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
name: "Eval",
|
|
139
|
-
params: [
|
|
140
|
-
{
|
|
141
|
-
name: "Body",
|
|
142
|
-
inputType: "textarea",
|
|
143
|
-
checker: nonEmptyChecker,
|
|
144
|
-
value: "ctx.getBeanDefinitionNames().length"
|
|
145
|
-
}
|
|
146
|
-
],
|
|
147
|
-
toPayload: (params: string[]) => JSON.stringify({
|
|
148
|
-
...commonMap(),
|
|
149
|
-
type: "EVAL",
|
|
150
|
-
body: params[0]
|
|
151
|
-
})
|
|
152
|
-
},
|
|
153
|
-
{
|
|
154
|
-
name: "Exec",
|
|
155
|
-
params: [
|
|
156
|
-
{
|
|
157
|
-
name: "Body",
|
|
158
|
-
inputType: "textarea",
|
|
159
|
-
checker: () => true,
|
|
160
|
-
value: `
|
|
161
|
-
try {
|
|
162
|
-
ApplicationContext ctx =
|
|
163
|
-
(ApplicationContext) SpringUtils.getSpringBootApplicationContext();
|
|
164
|
-
Global.info(Arrays.toString(ctx.getBeanDefinitionNames()));
|
|
165
|
-
} catch (Exception e) {
|
|
166
|
-
Global.error(e.toString(), e);
|
|
167
|
-
}
|
|
168
|
-
`
|
|
169
|
-
}
|
|
170
|
-
],
|
|
171
|
-
toPayload: (params: string[]) => JSON.stringify({
|
|
172
|
-
...commonMap(),
|
|
173
|
-
type: "EXEC",
|
|
174
|
-
body: `package w;
|
|
175
|
-
import w.Global;
|
|
176
|
-
import w.util.SpringUtils;
|
|
177
|
-
import org.springframework.context.ApplicationContext;
|
|
178
|
-
import java.util.*;
|
|
179
|
-
public class Exec{
|
|
180
|
-
public void exec() {${params[0] ?? ""}}
|
|
181
|
-
}`
|
|
182
|
-
})
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
name: "Reset",
|
|
186
|
-
params: [],
|
|
187
|
-
toPayload: () => JSON.stringify({ ...commonMap(), type: "RESET" })
|
|
188
|
-
}
|
|
189
|
-
]
|
|
190
|
-
|
|
191
|
-
export function buildInitialFormValues(actionIndex: number): string[] {
|
|
192
|
-
return menu[actionIndex].params.map((param: FunctionDefinition["params"][number]) => param.value)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function validateAction(actionIndex: number, values: string[]): boolean {
|
|
196
|
-
return menu[actionIndex].params.every((param: FunctionDefinition["params"][number], index: number) => param.checker(values[index] ?? ""))
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function buildPayload(actionIndex: number, values: string[]): string {
|
|
200
|
-
return menu[actionIndex].toPayload(values)
|
|
201
|
-
}
|
package/src/state.test.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { appendLog, backToMenu, createInitialState, nextFocus, openAction, previousFocus, updateFormValue, updateSelectedAction } from "./state.js"
|
|
3
|
-
|
|
4
|
-
describe("state helpers", () => {
|
|
5
|
-
test("openAction builds form state", () => {
|
|
6
|
-
const initial = updateSelectedAction(createInitialState(), 3)
|
|
7
|
-
const opened = openAction(initial)
|
|
8
|
-
expect(opened.screen).toBe("form")
|
|
9
|
-
expect(opened.focusIndex).toBe(0)
|
|
10
|
-
expect(opened.formValues.length).toBe(4)
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
test("focus wraps across inputs and submit button", () => {
|
|
14
|
-
const opened = openAction(updateSelectedAction(createInitialState(), 0))
|
|
15
|
-
expect(nextFocus(opened, 2).focusIndex).toBe(1)
|
|
16
|
-
expect(nextFocus({ ...opened, focusIndex: 2 }, 2).focusIndex).toBe(0)
|
|
17
|
-
expect(previousFocus(opened, 2).focusIndex).toBe(2)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
test("updateFormValue and backToMenu keep expected data", () => {
|
|
21
|
-
const opened = openAction(updateSelectedAction(createInitialState(), 5))
|
|
22
|
-
const updated = updateFormValue(opened, 0, "hello")
|
|
23
|
-
expect(updated.formValues[0]).toBe("hello")
|
|
24
|
-
expect(backToMenu(updated).screen).toBe("menu")
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test("appendLog keeps newest messages first", () => {
|
|
28
|
-
const state = appendLog(appendLog(createInitialState(), "first"), "second")
|
|
29
|
-
expect(state.logs).toEqual(["first", "second"])
|
|
30
|
-
})
|
|
31
|
-
})
|
package/src/state.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { buildInitialFormValues } from "./protocol.js"
|
|
2
|
-
import type { AppState, ConnectionStatus } from "./types.js"
|
|
3
|
-
|
|
4
|
-
const logMaxLength = 200
|
|
5
|
-
|
|
6
|
-
export function createInitialState(): AppState {
|
|
7
|
-
return {
|
|
8
|
-
screen: "menu",
|
|
9
|
-
selectedActionIndex: 0,
|
|
10
|
-
focusIndex: 0,
|
|
11
|
-
formValues: [],
|
|
12
|
-
logs: [],
|
|
13
|
-
connectionStatus: "idle"
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function openAction(state: AppState, actionIndex = state.selectedActionIndex): AppState {
|
|
18
|
-
return {
|
|
19
|
-
...state,
|
|
20
|
-
screen: "form",
|
|
21
|
-
selectedActionIndex: actionIndex,
|
|
22
|
-
focusIndex: 0,
|
|
23
|
-
formValues: buildInitialFormValues(actionIndex)
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function backToMenu(state: AppState): AppState {
|
|
28
|
-
return {
|
|
29
|
-
...state,
|
|
30
|
-
screen: "menu",
|
|
31
|
-
focusIndex: 0
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function updateSelectedAction(state: AppState, actionIndex: number): AppState {
|
|
36
|
-
return {
|
|
37
|
-
...state,
|
|
38
|
-
selectedActionIndex: actionIndex
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function updateFormValue(state: AppState, index: number, value: string): AppState {
|
|
43
|
-
const formValues = [...state.formValues]
|
|
44
|
-
formValues[index] = value
|
|
45
|
-
return {
|
|
46
|
-
...state,
|
|
47
|
-
formValues
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function nextFocus(state: AppState, inputCount: number): AppState {
|
|
52
|
-
return {
|
|
53
|
-
...state,
|
|
54
|
-
focusIndex: (state.focusIndex + 1) % (inputCount + 1)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function previousFocus(state: AppState, inputCount: number): AppState {
|
|
59
|
-
return {
|
|
60
|
-
...state,
|
|
61
|
-
focusIndex: (state.focusIndex - 1 + inputCount + 1) % (inputCount + 1)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function appendLog(state: AppState, message: string): AppState {
|
|
66
|
-
return {
|
|
67
|
-
...state,
|
|
68
|
-
logs: [...state.logs, message].slice(-logMaxLength)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function setConnectionStatus(state: AppState, status: ConnectionStatus): AppState {
|
|
73
|
-
return {
|
|
74
|
-
...state,
|
|
75
|
-
connectionStatus: status
|
|
76
|
-
}
|
|
77
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
export type ParamOption = {
|
|
2
|
-
name: string
|
|
3
|
-
description: string
|
|
4
|
-
value: string
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export type ParamInputType = "text" | "textarea" | "select"
|
|
8
|
-
|
|
9
|
-
export type ParamDefinition = {
|
|
10
|
-
name: string
|
|
11
|
-
inputType: ParamInputType
|
|
12
|
-
checker: (value: string) => boolean
|
|
13
|
-
value: string
|
|
14
|
-
options?: ParamOption[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type FunctionDefinition = {
|
|
18
|
-
name: string
|
|
19
|
-
params: ParamDefinition[]
|
|
20
|
-
toPayload: (params: string[]) => string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type Screen = "menu" | "form"
|
|
24
|
-
|
|
25
|
-
export type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error"
|
|
26
|
-
|
|
27
|
-
export type AppState = {
|
|
28
|
-
screen: Screen
|
|
29
|
-
selectedActionIndex: number
|
|
30
|
-
focusIndex: number
|
|
31
|
-
formValues: string[]
|
|
32
|
-
logs: string[]
|
|
33
|
-
connectionStatus: ConnectionStatus
|
|
34
|
-
}
|
package/src/ws.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
export type ConnectionOptions = {
|
|
2
|
-
url: string
|
|
3
|
-
enabled: boolean
|
|
4
|
-
onLog: (message: string) => void
|
|
5
|
-
onStatusChange: (status: "connecting" | "connected" | "disconnected" | "error") => void
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function formatLog(content: string): string {
|
|
9
|
-
const timestamp = new Date()
|
|
10
|
-
const formatted = timestamp.toISOString().replace("T", " ").slice(0, 19)
|
|
11
|
-
return `[${formatted}] ${content}`
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function connectWebSocket(options: ConnectionOptions): { send: (message: string) => void; dispose: () => void } {
|
|
15
|
-
let ws: WebSocket | null = null
|
|
16
|
-
let closed = false
|
|
17
|
-
|
|
18
|
-
if (options.enabled) {
|
|
19
|
-
options.onStatusChange("connecting")
|
|
20
|
-
try {
|
|
21
|
-
ws = new WebSocket(options.url)
|
|
22
|
-
ws.onopen = () => {
|
|
23
|
-
options.onStatusChange("connected")
|
|
24
|
-
options.onLog(formatLog(`WebSocket connected: ${options.url}`))
|
|
25
|
-
}
|
|
26
|
-
ws.onmessage = (event) => {
|
|
27
|
-
try {
|
|
28
|
-
const parsed = JSON.parse(String(event.data)) as { content?: string }
|
|
29
|
-
options.onLog(formatLog(parsed.content ?? String(event.data)))
|
|
30
|
-
} catch {
|
|
31
|
-
options.onLog(formatLog(String(event.data)))
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
ws.onerror = () => {
|
|
35
|
-
options.onStatusChange("error")
|
|
36
|
-
options.onLog(formatLog(`WebSocket error: ${options.url}`))
|
|
37
|
-
}
|
|
38
|
-
ws.onclose = () => {
|
|
39
|
-
if (!closed) {
|
|
40
|
-
options.onStatusChange("disconnected")
|
|
41
|
-
options.onLog(formatLog(`WebSocket disconnected: ${options.url}`))
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
} catch (error) {
|
|
45
|
-
options.onStatusChange("error")
|
|
46
|
-
options.onLog(formatLog(`WebSocket connect failed (${options.url}): ${String(error)}`))
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
send(message: string) {
|
|
52
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
53
|
-
options.onLog(formatLog("WebSocket is not connected"))
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
ws.send(message)
|
|
57
|
-
},
|
|
58
|
-
dispose() {
|
|
59
|
-
closed = true
|
|
60
|
-
ws?.close()
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"lib": ["ESNext", "DOM"],
|
|
4
|
-
"target": "ESNext",
|
|
5
|
-
"module": "NodeNext",
|
|
6
|
-
"moduleResolution": "NodeNext",
|
|
7
|
-
"jsx": "react-jsx",
|
|
8
|
-
"jsxImportSource": "@opentui/react",
|
|
9
|
-
"strict": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"noEmit": true,
|
|
12
|
-
"types": ["bun-types"]
|
|
13
|
-
},
|
|
14
|
-
"include": ["src/**/*", "build.ts"]
|
|
15
|
-
}
|
|
File without changes
|