@sjawhar/opencode-legion-envoy 0.1.10 → 0.2.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.
@@ -0,0 +1,135 @@
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
+ });
@@ -0,0 +1,85 @@
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
+ });
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { appendFileSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+
5
+ /**
6
+ * Build marker. Bump on every change so the debug log proves whether the
7
+ * running OpenCode process loaded the new module — the TUI plugin is loaded via
8
+ * `await import()` (cached by URL), so a behavior fix here only takes effect
9
+ * after a full OpenCode process restart, not just a new TUI session.
10
+ */
11
+ export const CLIPBOARD_BUILD_ID = "renderer-osc52-2026-06-06-2";
12
+
13
+ /**
14
+ * Clipboard copy for the OpenCode TUI plugin.
15
+ *
16
+ * Copies MUST go through the renderer's own OSC 52 writer
17
+ * (api.renderer.copyToClipboardOSC52). opentui's renderer owns the terminal and
18
+ * serializes all output via its native writer; it also applies tmux/screen DCS
19
+ * passthrough wrapping and gates on terminal OSC 52 support (see opentui zig
20
+ * terminal.zig writeClipboard). Writing a raw OSC 52 to process.stdout bypasses
21
+ * that serialization and races with frame output, so copies landed only
22
+ * intermittently ("works once right after a native copy, then stops") — that was
23
+ * the flakiness, now removed.
24
+ *
25
+ * A native OS clipboard command is kept as a fallback for terminals without
26
+ * OSC 52 support; it is a subprocess and never writes to the terminal stream.
27
+ */
28
+
29
+ type Runner = (cmd: string, args: string[], text: string) => boolean;
30
+
31
+ export interface ClipboardRenderer {
32
+ copyToClipboardOSC52(text: string): boolean;
33
+ }
34
+
35
+ function debugClipboard(event: Record<string, unknown>): void {
36
+ const path = process.env.OPENCODE_TUI_CLIPBOARD_DEBUG;
37
+ if (!path) return;
38
+ try {
39
+ appendFileSync(
40
+ path,
41
+ `${JSON.stringify({
42
+ ts: new Date().toISOString(),
43
+ buildId: CLIPBOARD_BUILD_ID,
44
+ pid: process.pid,
45
+ entry: import.meta.url,
46
+ isTTY: process.stdout.isTTY ?? null,
47
+ term: process.env.TERM ?? null,
48
+ tmux: Boolean(process.env.TMUX),
49
+ sty: Boolean(process.env.STY),
50
+ display: Boolean(process.env.DISPLAY),
51
+ wayland: Boolean(process.env.WAYLAND_DISPLAY),
52
+ ...event,
53
+ })}\n`
54
+ );
55
+ } catch {
56
+ // diagnostics must never break copy
57
+ }
58
+ }
59
+
60
+ function copyViaRenderer(renderer: ClipboardRenderer | undefined, text: string): boolean {
61
+ if (!renderer) return false;
62
+ try {
63
+ const ok = renderer.copyToClipboardOSC52(text);
64
+ debugClipboard({ phase: "renderer-osc52", returned: ok });
65
+ return ok;
66
+ } catch (error) {
67
+ debugClipboard({ phase: "renderer-osc52", error: String(error) });
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function defaultWhich(cmd: string): boolean {
73
+ try {
74
+ return spawnSync("which", [cmd], { stdio: "ignore" }).status === 0;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function defaultRun(cmd: string, args: string[], text: string): boolean {
81
+ try {
82
+ const result = spawnSync(cmd, args, {
83
+ input: text,
84
+ stdio: ["pipe", "ignore", "ignore"],
85
+ timeout: 1000,
86
+ });
87
+ return result.status === 0;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export interface NativeCopyDeps {
94
+ which?: (cmd: string) => boolean;
95
+ run?: Runner;
96
+ os?: NodeJS.Platform;
97
+ env?: NodeJS.ProcessEnv;
98
+ }
99
+
100
+ export function copyNative(text: string, deps: NativeCopyDeps = {}): boolean {
101
+ const which = deps.which ?? defaultWhich;
102
+ const run = deps.run ?? defaultRun;
103
+ const os = deps.os ?? platform();
104
+ const env = deps.env ?? process.env;
105
+
106
+ if (os === "darwin" && which("osascript")) {
107
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
108
+ return run("osascript", ["-e", `set the clipboard to "${escaped}"`], "");
109
+ }
110
+
111
+ if (os === "linux") {
112
+ if (env.WAYLAND_DISPLAY && which("wl-copy")) return run("wl-copy", [], text);
113
+ if (which("xclip")) return run("xclip", ["-selection", "clipboard"], text);
114
+ if (which("xsel")) return run("xsel", ["--clipboard", "--input"], text);
115
+ }
116
+
117
+ if (os === "win32") {
118
+ return run(
119
+ "powershell.exe",
120
+ [
121
+ "-NonInteractive",
122
+ "-NoProfile",
123
+ "-Command",
124
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
125
+ ],
126
+ text
127
+ );
128
+ }
129
+
130
+ return false;
131
+ }
132
+
133
+ export function copyToClipboard(text: string, renderer?: ClipboardRenderer): boolean {
134
+ debugClipboard({ phase: "copy-start", textLength: text.length });
135
+ const viaRenderer = copyViaRenderer(renderer, text);
136
+ const native = viaRenderer ? false : copyNative(text);
137
+ const ok = viaRenderer || native;
138
+ debugClipboard({ phase: "copy-result", viaRenderer, native, ok });
139
+ return ok;
140
+ }
@@ -0,0 +1,93 @@
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
+ });
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { logger } from "../log";
5
+ import { type EnvoyConfig, EnvoyConfigSchema } from "./schema";
6
+
7
+ export interface LoadEnvoyConfigOptions {
8
+ homeDir?: string;
9
+ }
10
+
11
+ function readConfigFile(filePath: string): EnvoyConfig | null {
12
+ if (!existsSync(filePath)) return null;
13
+ try {
14
+ const content = readFileSync(filePath, "utf-8");
15
+ const raw = JSON.parse(content) as unknown;
16
+ const parsed = EnvoyConfigSchema.safeParse(raw);
17
+ if (!parsed.success) {
18
+ const issues = parsed.error.issues
19
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
20
+ .join(", ");
21
+ logger.warn(`[envoy-plugin] Invalid config at ${filePath}: ${issues}`);
22
+ return null;
23
+ }
24
+ return parsed.data as EnvoyConfig;
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ logger.warn(`[envoy-plugin] Failed to load config at ${filePath}: ${message}`);
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function mergeConfig(base: EnvoyConfig, override: EnvoyConfig): EnvoyConfig {
33
+ return {
34
+ ...base,
35
+ ...override,
36
+ dispatch:
37
+ base.dispatch || override.dispatch
38
+ ? {
39
+ ...base.dispatch,
40
+ ...override.dispatch,
41
+ }
42
+ : undefined,
43
+ };
44
+ }
45
+
46
+ export async function loadEnvoyConfig(
47
+ directory: string,
48
+ options: LoadEnvoyConfigOptions = {}
49
+ ): Promise<EnvoyConfig> {
50
+ const homeDir = options.homeDir ?? os.homedir();
51
+ const userConfigPath = path.join(homeDir, ".config", "opencode", "envoy.json");
52
+ const repoConfigPath = path.join(directory, ".opencode", "envoy.json");
53
+
54
+ let merged: EnvoyConfig = {};
55
+ const userConfig = readConfigFile(userConfigPath);
56
+ if (userConfig) merged = mergeConfig(merged, userConfig);
57
+ const repoConfig = readConfigFile(repoConfigPath);
58
+ if (repoConfig) merged = mergeConfig(merged, repoConfig);
59
+ return merged;
60
+ }
61
+
62
+ export type { DispatchConfig, EnvoyConfig } from "./schema";
@@ -0,0 +1,26 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+
3
+ const z = tool.schema;
4
+
5
+ export const DispatchConfigSchema = z
6
+ .object({
7
+ enabled: z.boolean().optional(),
8
+ serverUrl: z.string().url().optional(),
9
+ defaultRepo: z
10
+ .string()
11
+ .regex(/^[^/]+\/[^/]+$/)
12
+ .optional(),
13
+ appClientId: z.string().optional(),
14
+ })
15
+ .strict();
16
+
17
+ export const EnvoyConfigSchema = z
18
+ .object({
19
+ $schema: z.string().optional(),
20
+ natsUrls: z.array(z.string()).optional(),
21
+ dispatch: DispatchConfigSchema.optional(),
22
+ })
23
+ .passthrough();
24
+
25
+ export type DispatchConfig = ReturnType<typeof DispatchConfigSchema.parse>;
26
+ export type EnvoyConfig = ReturnType<typeof EnvoyConfigSchema.parse>;