@sjawhar/opencode-legion-envoy 0.6.0 → 0.6.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.
@@ -1,135 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
-
6
- import { createLogger } from "../log";
7
-
8
- let logDir: string;
9
-
10
- beforeEach(async () => {
11
- logDir = await mkdtemp(path.join(tmpdir(), "envoy-plugin-log-"));
12
- });
13
-
14
- afterEach(async () => {
15
- await rm(logDir, { recursive: true, force: true });
16
- });
17
-
18
- describe("envoy-plugin logger", () => {
19
- // The whole point of this module: plugins run in-process with OpenCode's
20
- // TUI, so any console.warn/console.error byte goes straight to the terminal
21
- // and corrupts the render. Plugin diagnostics must go to a file instead.
22
- it("writes warn/error/info to the configured log file, NOT to console", async () => {
23
- const consoleWarn = mock(() => {});
24
- const consoleError = mock(() => {});
25
- const consoleLog = mock(() => {});
26
- const originalWarn = console.warn;
27
- const originalError = console.error;
28
- const originalLog = console.log;
29
- console.warn = consoleWarn;
30
- console.error = consoleError;
31
- console.log = consoleLog;
32
- try {
33
- const logger = createLogger({ logDir });
34
- logger.warn("a-warn");
35
- logger.error("an-error");
36
- logger.info("some-info");
37
- await logger.flush();
38
-
39
- expect(consoleWarn).not.toHaveBeenCalled();
40
- expect(consoleError).not.toHaveBeenCalled();
41
- expect(consoleLog).not.toHaveBeenCalled();
42
-
43
- const logFile = path.join(logDir, "envoy-plugin.log");
44
- const contents = await readFile(logFile, "utf-8");
45
- expect(contents).toContain("a-warn");
46
- expect(contents).toContain("an-error");
47
- expect(contents).toContain("some-info");
48
- expect(contents).toContain("WARN");
49
- expect(contents).toContain("ERROR");
50
- expect(contents).toContain("INFO");
51
- } finally {
52
- console.warn = originalWarn;
53
- console.error = originalError;
54
- console.log = originalLog;
55
- }
56
- });
57
-
58
- it("creates the log directory if it doesn't exist", async () => {
59
- const nested = path.join(logDir, "deeply", "nested");
60
- const logger = createLogger({ logDir: nested });
61
- logger.warn("hello");
62
- await logger.flush();
63
- const contents = await readFile(path.join(nested, "envoy-plugin.log"), "utf-8");
64
- expect(contents).toContain("hello");
65
- });
66
-
67
- it("prefixes each line with an ISO timestamp so logs are sortable", async () => {
68
- const logger = createLogger({ logDir });
69
- logger.warn("x");
70
- await logger.flush();
71
- const contents = await readFile(path.join(logDir, "envoy-plugin.log"), "utf-8");
72
- // Match leading ISO-8601 datetime like 2026-05-30T22:35:01.123Z
73
- expect(contents).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z /);
74
- });
75
-
76
- it("appends across calls instead of truncating", async () => {
77
- const logger = createLogger({ logDir });
78
- logger.warn("first");
79
- logger.warn("second");
80
- await logger.flush();
81
- const contents = await readFile(path.join(logDir, "envoy-plugin.log"), "utf-8");
82
- expect(contents).toContain("first");
83
- expect(contents).toContain("second");
84
- expect(contents.split("\n").filter((l) => l.length > 0).length).toBe(2);
85
- });
86
-
87
- it("never throws on logging errors — diagnostics must not crash the host", async () => {
88
- // Point at a path that cannot be created (a parent that exists as a file).
89
- const blockingFile = path.join(logDir, "blocker");
90
- const fs = await import("node:fs/promises");
91
- await fs.writeFile(blockingFile, "");
92
- const logger = createLogger({ logDir: path.join(blockingFile, "child") });
93
- expect(() => logger.warn("x")).not.toThrow();
94
- await expect(logger.flush()).resolves.toBeUndefined();
95
- });
96
- });
97
-
98
- describe("no console.* in plugin source (regression guard)", () => {
99
- // Plugins load in-process with OpenCode's TUI; any direct console.warn /
100
- // console.error / console.log write goes straight to the terminal and
101
- // corrupts the render. All diagnostics must route through the file logger.
102
- it("does not write to console anywhere outside __tests__ and log.ts", async () => {
103
- const { readdir, readFile, stat } = await import("node:fs/promises");
104
- const root = path.resolve(import.meta.dir, "..");
105
-
106
- async function walk(dir: string): Promise<string[]> {
107
- const entries = await readdir(dir);
108
- const out: string[] = [];
109
- for (const name of entries) {
110
- if (name === "__tests__" || name === "node_modules") continue;
111
- const full = path.join(dir, name);
112
- const s = await stat(full);
113
- if (s.isDirectory()) out.push(...(await walk(full)));
114
- else if (name.endsWith(".ts") || name.endsWith(".tsx")) out.push(full);
115
- }
116
- return out;
117
- }
118
-
119
- const files = await walk(root);
120
- const offenders: Array<{ file: string; line: number; text: string }> = [];
121
- for (const file of files) {
122
- // log.ts is allowed to mention console in its module docstring — it's
123
- // the module explaining the prohibition.
124
- if (file.endsWith("/log.ts")) continue;
125
- const lines = (await readFile(file, "utf-8")).split("\n");
126
- lines.forEach((text, idx) => {
127
- if (/^\s*(?:\/\/|\*| \*)/.test(text)) return; // comments are fine
128
- if (/\bconsole\.(warn|error|log)\s*\(/.test(text)) {
129
- offenders.push({ file: path.relative(root, file), line: idx + 1, text });
130
- }
131
- });
132
- }
133
- expect(offenders).toEqual([]);
134
- });
135
- });
@@ -1,124 +0,0 @@
1
- import { describe, expect, it, mock } from "bun:test";
2
- import { resolvePort } from "../port";
3
-
4
- describe("resolvePort", () => {
5
- const noopExec = (async () => {
6
- throw new Error("ss not available");
7
- }) as Parameters<typeof resolvePort>[1];
8
-
9
- const ssOutput = (pid: number, port: number) =>
10
- [
11
- "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
12
- `LISTEN 0 511 127.0.0.1:${port} 0.0.0.0:* users:(("bun",pid=${pid},fd=6))`,
13
- "",
14
- ].join("\n");
15
-
16
- describe("URL port extraction", () => {
17
- it("returns port from standard non-default URL", async () => {
18
- expect(await resolvePort(new URL("http://127.0.0.1:4096"), noopExec)).toBe(4096);
19
- });
20
-
21
- it("returns port for high serve ports", async () => {
22
- expect(await resolvePort(new URL("http://127.0.0.1:13381"), noopExec)).toBe(13381);
23
- });
24
-
25
- it("returns port for localhost URLs", async () => {
26
- expect(await resolvePort(new URL("http://localhost:4096"), noopExec)).toBe(4096);
27
- });
28
-
29
- it("returns port for IPv6 URLs", async () => {
30
- expect(await resolvePort(new URL("http://[::1]:4096"), noopExec)).toBe(4096);
31
- });
32
-
33
- it("skips exec entirely when URL port is valid", async () => {
34
- const exec = mock(async () => ssOutput(process.pid, 9999));
35
- expect(await resolvePort(new URL("http://127.0.0.1:4096"), exec as never)).toBe(4096);
36
- expect(exec).not.toHaveBeenCalled();
37
- });
38
- });
39
-
40
- describe("ss fallback", () => {
41
- it("uses ss when URL has no port (default HTTP)", async () => {
42
- const exec = mock(async () => ssOutput(process.pid, 4096));
43
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
44
- expect(exec).toHaveBeenCalledWith("ss", ["-tlnp"], { encoding: "utf-8" });
45
- });
46
-
47
- it("uses ss when URL port is 0", async () => {
48
- // URL("http://127.0.0.1:0").port is "0", which is not > 0
49
- const exec = mock(async () => ssOutput(process.pid, 13381));
50
- expect(await resolvePort(new URL("http://127.0.0.1:0"), exec as never)).toBe(13381);
51
- });
52
-
53
- it("ignores ss lines with different PIDs", async () => {
54
- const exec = mock(async () => ssOutput(99999, 4096));
55
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
56
- });
57
-
58
- it("handles multiple ss entries and picks the matching PID", async () => {
59
- const output = [
60
- "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
61
- `LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("node",pid=99999,fd=6))`,
62
- `LISTEN 0 511 127.0.0.1:4096 0.0.0.0:* users:(("bun",pid=${process.pid},fd=7))`,
63
- "",
64
- ].join("\n");
65
- const exec = mock(async () => output);
66
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
67
- });
68
-
69
- it("handles IPv6 listening addresses in ss output", async () => {
70
- const output = [
71
- "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process",
72
- `LISTEN 0 511 [::]:4096 [::]:* users:(("bun",pid=${process.pid},fd=6))`,
73
- "",
74
- ].join("\n");
75
- const exec = mock(async () => output);
76
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(4096);
77
- });
78
-
79
- it("works with async exec that resolves after a delay", async () => {
80
- const exec = mock(
81
- () =>
82
- new Promise<string>((resolve) =>
83
- setTimeout(() => resolve(ssOutput(process.pid, 5555)), 10)
84
- )
85
- );
86
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBe(5555);
87
- });
88
- });
89
-
90
- describe("failure cases", () => {
91
- it("returns null when ss is not available", async () => {
92
- expect(await resolvePort(new URL("http://127.0.0.1"), noopExec)).toBeNull();
93
- });
94
-
95
- it("returns null when ss returns empty output", async () => {
96
- const exec = mock(async () => "");
97
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
98
- });
99
-
100
- it("returns null when async exec rejects", async () => {
101
- const exec = mock(async () => {
102
- throw new Error("command failed");
103
- });
104
- expect(await resolvePort(new URL("http://127.0.0.1"), exec as never)).toBeNull();
105
- });
106
- });
107
-
108
- describe("edge cases", () => {
109
- it("URL.port is empty string for default HTTP port 80", async () => {
110
- // http://127.0.0.1:80 → URL.port is "" (80 is default for http)
111
- const url = new URL("http://127.0.0.1:80");
112
- expect(url.port).toBe("");
113
- const exec = mock(async () => ssOutput(process.pid, 4096));
114
- expect(await resolvePort(url, exec as never)).toBe(4096);
115
- });
116
-
117
- it("URL.port is empty string for default HTTPS port 443", async () => {
118
- const url = new URL("https://127.0.0.1:443");
119
- expect(url.port).toBe("");
120
- const exec = mock(async () => ssOutput(process.pid, 4096));
121
- expect(await resolvePort(url, exec as never)).toBe(4096);
122
- });
123
- });
124
- });
@@ -1,34 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { portFromSsOutput } from "../ss";
3
-
4
- const header = "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process";
5
-
6
- function listen(local: string, pid: number): string {
7
- return `LISTEN 0 511 ${local} 0.0.0.0:* users:(("bun",pid=${pid},fd=6))`;
8
- }
9
-
10
- describe("portFromSsOutput", () => {
11
- test("returns null when the process id has no listening socket", () => {
12
- const output = [header, listen("127.0.0.1:4096", 99_999)].join("\n");
13
-
14
- expect(portFromSsOutput(output, 12_345)).toBeNull();
15
- });
16
-
17
- test("skips matching process rows whose local address column is malformed", () => {
18
- const output = [header, listen("not-a-local-address", 123)].join("\n");
19
-
20
- expect(portFromSsOutput(output, 123)).toBeNull();
21
- });
22
-
23
- test("ignores zero and negative local ports", () => {
24
- const output = [header, listen("127.0.0.1:0", 123), listen("127.0.0.1:-1", 123)].join("\n");
25
-
26
- expect(portFromSsOutput(output, 123)).toBeNull();
27
- });
28
-
29
- test("matches the full pid token before returning a port", () => {
30
- const output = [header, listen("127.0.0.1:9999", 123), listen("127.0.0.1:4444", 12)].join("\n");
31
-
32
- expect(portFromSsOutput(output, 12)).toBe(4444);
33
- });
34
- });
@@ -1,85 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import {
3
- parsePort,
4
- resolveCurrentProcessPort,
5
- resolveSessionProcessPort,
6
- resolveTuiPort,
7
- } from "../tui-port";
8
-
9
- describe("parsePort", () => {
10
- it("returns the port from a standard http URL", () => {
11
- expect(parsePort("http://localhost:4096")).toBe(4096);
12
- });
13
-
14
- it("returns the port from a 127.0.0.1 URL", () => {
15
- expect(parsePort("http://127.0.0.1:13381")).toBe(13381);
16
- });
17
-
18
- it("returns the port from an https URL with explicit port", () => {
19
- expect(parsePort("https://example.com:8443")).toBe(8443);
20
- });
21
-
22
- it("returns null when the URL has no explicit port", () => {
23
- expect(parsePort("http://localhost")).toBe(null);
24
- });
25
-
26
- it("returns null for the empty string", () => {
27
- expect(parsePort("")).toBe(null);
28
- });
29
-
30
- it("returns null for a non-URL string", () => {
31
- expect(parsePort("not a url")).toBe(null);
32
- });
33
-
34
- it("returns null for a malformed port", () => {
35
- expect(parsePort("http://localhost:abc")).toBe(null);
36
- });
37
-
38
- it("returns null for undefined input", () => {
39
- expect(parsePort(undefined)).toBe(null);
40
- });
41
- });
42
-
43
- describe("resolveCurrentProcessPort", () => {
44
- it("returns a listening port for the current process from ss output", () => {
45
- const exec = () =>
46
- `LISTEN 0 512 127.0.0.1:41895 0.0.0.0:* users:(("opencode",pid=${process.pid},fd=23))`;
47
-
48
- expect(resolveCurrentProcessPort(exec)).toBe(41895);
49
- });
50
-
51
- it("returns null when ss has no current process match", () => {
52
- const exec = () => 'LISTEN 0 512 127.0.0.1:41895 0.0.0.0:* users:(("opencode",pid=123,fd=23))';
53
-
54
- expect(resolveCurrentProcessPort(exec)).toBe(null);
55
- });
56
- });
57
-
58
- describe("resolveTuiPort", () => {
59
- it("prefers the base URL port", () => {
60
- const exec = () => {
61
- throw new Error("should not call ss");
62
- };
63
-
64
- expect(resolveTuiPort("http://127.0.0.1:4096", undefined, exec)).toBe(4096);
65
- });
66
-
67
- it("falls back to the current process listening port", () => {
68
- const exec = () =>
69
- `LISTEN 0 512 127.0.0.1:35291 0.0.0.0:* users:(("opencode",pid=${process.pid},fd=23))`;
70
-
71
- expect(resolveTuiPort(undefined, undefined, exec)).toBe(35291);
72
- });
73
- });
74
-
75
- describe("resolveSessionProcessPort", () => {
76
- it("resolves the listening port for an opencode session process", () => {
77
- const sessionID = "ses_test";
78
- const exec = (command: string) => {
79
- if (command === "ps") return `123 opencode --port 0 -s ${sessionID}`;
80
- return 'LISTEN 0 512 127.0.0.1:42823 0.0.0.0:* users:(("opencode",pid=123,fd=23))';
81
- };
82
-
83
- expect(resolveSessionProcessPort(sessionID, exec)).toBe(42823);
84
- });
85
- });
@@ -1,93 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
- import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- import { logger } from "../../log";
6
- import { loadEnvoyConfig } from "..";
7
-
8
- describe("loadEnvoyConfig", () => {
9
- let homeDir: string;
10
- let repoDir: string;
11
- let warn: ReturnType<typeof spyOn>;
12
-
13
- beforeEach(async () => {
14
- homeDir = await mkdtemp(path.join(tmpdir(), "envoy-home-"));
15
- repoDir = await mkdtemp(path.join(tmpdir(), "envoy-repo-"));
16
- // Spy on the file logger — plugin diagnostics route there instead of
17
- // console to keep stderr clean for the in-process TUI host.
18
- warn = spyOn(logger, "warn").mockImplementation(() => {});
19
- });
20
-
21
- afterEach(() => {
22
- warn.mockRestore();
23
- });
24
-
25
- async function writeJson(filePath: string, value: unknown) {
26
- await mkdir(path.dirname(filePath), { recursive: true });
27
- await writeFile(filePath, JSON.stringify(value));
28
- }
29
-
30
- it("loads user-only config", async () => {
31
- await writeJson(path.join(homeDir, ".config", "opencode", "envoy.json"), {
32
- natsUrls: ["nats://127.0.0.1:4222"],
33
- dispatch: { enabled: true, defaultRepo: "sjawhar/legion" },
34
- });
35
-
36
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({
37
- natsUrls: ["nats://127.0.0.1:4222"],
38
- dispatch: { enabled: true, defaultRepo: "sjawhar/legion" },
39
- });
40
- });
41
-
42
- it("loads repo-only config", async () => {
43
- await writeJson(path.join(repoDir, ".opencode", "envoy.json"), {
44
- dispatch: { serverUrl: "http://localhost:8766" },
45
- });
46
-
47
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({
48
- dispatch: { serverUrl: "http://localhost:8766" },
49
- });
50
- });
51
-
52
- it("shallow-merges user config with repo config and lets repo dispatch keys win", async () => {
53
- await writeJson(path.join(homeDir, ".config", "opencode", "envoy.json"), {
54
- natsUrls: ["nats://user:4222"],
55
- dispatch: { enabled: false, defaultRepo: "sjawhar/legion" },
56
- });
57
- await writeJson(path.join(repoDir, ".opencode", "envoy.json"), {
58
- dispatch: { enabled: true, serverUrl: "http://localhost:8766" },
59
- });
60
-
61
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({
62
- natsUrls: ["nats://user:4222"],
63
- dispatch: {
64
- enabled: true,
65
- defaultRepo: "sjawhar/legion",
66
- serverUrl: "http://localhost:8766",
67
- },
68
- });
69
- });
70
-
71
- it("returns empty config and warns on invalid JSON", async () => {
72
- const configPath = path.join(homeDir, ".config", "opencode", "envoy.json");
73
- await mkdir(path.dirname(configPath), { recursive: true });
74
- await writeFile(configPath, "{");
75
-
76
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({});
77
- expect(warn).toHaveBeenCalled();
78
- });
79
-
80
- it("returns empty config and warns on schema-invalid JSON", async () => {
81
- await writeJson(path.join(repoDir, ".opencode", "envoy.json"), {
82
- natsUrls: "nats://127.0.0.1:4222",
83
- });
84
-
85
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({});
86
- expect(warn).toHaveBeenCalled();
87
- });
88
-
89
- it("returns empty config when files are missing", async () => {
90
- await expect(loadEnvoyConfig(repoDir, { homeDir })).resolves.toEqual({});
91
- expect(warn).not.toHaveBeenCalled();
92
- });
93
- });
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "Bundler",
6
- "baseUrl": ".",
7
- "paths": {
8
- "@legion/contracts": ["../contracts/src/index.ts"],
9
- "@legion/envoy-client/defaults": ["../envoy-client/src/defaults.ts"],
10
- "@legion/envoy-client/tool-contract": ["../envoy-client/src/tool-contract.ts"],
11
- "@legion/envoy-client/transport": ["../envoy-client/src/transport.ts"]
12
- },
13
- "strict": true,
14
- "noEmit": true,
15
- "skipLibCheck": true,
16
- "jsx": "preserve",
17
- "types": ["bun"]
18
- },
19
- "include": ["bin/**/*.ts", "src/**/*.ts", "src/**/*.tsx"]
20
- }