oh-my-opencode 5.1.42 → 5.1.43

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.
Files changed (81) hide show
  1. package/dist/cli/index.js +109 -26
  2. package/dist/cli/service-auth/cli-keystore.fixture.d.ts +1 -7
  3. package/dist/cli/service-auth/index.d.ts +1 -1
  4. package/dist/cli/service-auth/keystore.d.ts +8 -2
  5. package/dist/cli/service-auth/secret-service-lock.d.ts +4 -0
  6. package/dist/cli/service-auth/session.d.ts +7 -1
  7. package/dist/cli-node/index.js +109 -26
  8. package/dist/index.js +13 -13
  9. package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
  10. package/dist/tui.js +13 -13
  11. package/package.json +13 -13
  12. package/packages/lsp-core/src/lsp/bun-command-output.ts +82 -0
  13. package/packages/lsp-core/src/lsp/bun-project-checks.test.ts +170 -0
  14. package/packages/lsp-core/src/lsp/bun-project-checks.ts +223 -0
  15. package/packages/lsp-core/src/lsp/fake-bun-test-support.ts +102 -0
  16. package/packages/lsp-core/src/lsp/process.ts +1 -1
  17. package/packages/lsp-core/src/tool-surface.test.ts +2 -1
  18. package/packages/lsp-core/src/tools/bun-directory-diagnostics.test.ts +113 -0
  19. package/packages/lsp-core/src/tools/bun-directory-diagnostics.ts +75 -0
  20. package/packages/lsp-core/src/tools/definitions.ts +2 -1
  21. package/packages/lsp-core/src/tools/diagnostics.ts +23 -6
  22. package/packages/lsp-core/src/tools/types.ts +1 -0
  23. package/packages/lsp-daemon/dist/cli.js +363 -35
  24. package/packages/lsp-daemon/dist/client.js +341 -13
  25. package/packages/lsp-daemon/dist/index.js +344 -16
  26. package/packages/lsp-tools-mcp/dist/cli.js +333 -5
  27. package/packages/lsp-tools-mcp/dist/mcp.js +333 -5
  28. package/packages/lsp-tools-mcp/dist/tools.js +333 -5
  29. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  30. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  33. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  35. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
  39. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +358 -36
  40. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  41. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  42. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  43. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  44. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  45. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  46. package/packages/omo-codex/plugin/components/telemetry/dist/cli.js +2 -0
  47. package/packages/omo-codex/plugin/components/telemetry/dist/posthog.js +2 -0
  48. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  49. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  50. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  51. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  52. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  53. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  54. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  55. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  71. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  72. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  73. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  74. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  77. package/packages/omo-codex/plugin/package-lock.json +17 -12
  78. package/packages/omo-codex/plugin/package.json +1 -1
  79. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  80. package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -2
  81. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-opencode",
3
- "version": "5.1.42",
3
+ "version": "5.1.43",
4
4
  "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
5
5
  "main": "./dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -238,18 +238,18 @@
238
238
  "typescript": "^7.0.2"
239
239
  },
240
240
  "optionalDependencies": {
241
- "oh-my-opencode-darwin-arm64": "5.1.42",
242
- "oh-my-opencode-darwin-x64": "5.1.42",
243
- "oh-my-opencode-darwin-x64-baseline": "5.1.42",
244
- "oh-my-opencode-linux-arm64": "5.1.42",
245
- "oh-my-opencode-linux-arm64-musl": "5.1.42",
246
- "oh-my-opencode-linux-x64": "5.1.42",
247
- "oh-my-opencode-linux-x64-baseline": "5.1.42",
248
- "oh-my-opencode-linux-x64-musl": "5.1.42",
249
- "oh-my-opencode-linux-x64-musl-baseline": "5.1.42",
250
- "oh-my-opencode-windows-arm64": "5.1.42",
251
- "oh-my-opencode-windows-x64": "5.1.42",
252
- "oh-my-opencode-windows-x64-baseline": "5.1.42"
241
+ "oh-my-opencode-darwin-arm64": "5.1.43",
242
+ "oh-my-opencode-darwin-x64": "5.1.43",
243
+ "oh-my-opencode-darwin-x64-baseline": "5.1.43",
244
+ "oh-my-opencode-linux-arm64": "5.1.43",
245
+ "oh-my-opencode-linux-arm64-musl": "5.1.43",
246
+ "oh-my-opencode-linux-x64": "5.1.43",
247
+ "oh-my-opencode-linux-x64-baseline": "5.1.43",
248
+ "oh-my-opencode-linux-x64-musl": "5.1.43",
249
+ "oh-my-opencode-linux-x64-musl-baseline": "5.1.43",
250
+ "oh-my-opencode-windows-arm64": "5.1.43",
251
+ "oh-my-opencode-windows-x64": "5.1.43",
252
+ "oh-my-opencode-windows-x64-baseline": "5.1.43"
253
253
  },
254
254
  "overrides": {
255
255
  "hono": "^4.13.8",
@@ -0,0 +1,82 @@
1
+ import { resolve } from "node:path";
2
+ import type { Diagnostic } from "./types.js";
3
+
4
+ export interface FileDiagnostic {
5
+ readonly file: string;
6
+ readonly diagnostic: Diagnostic;
7
+ }
8
+
9
+ const TSC_LINE = /^(.+?)\((\d+),(\d+)\): (error|warning|message) (TS\d+): (.*)$/;
10
+ const TSC_SEVERITY: Record<string, number> = { error: 1, warning: 2, message: 3 };
11
+
12
+ export function parseBunCheckOutput(stdout: string, cwd: string): FileDiagnostic[] {
13
+ const results: Array<{ file: string; diagnostic: Diagnostic }> = [];
14
+ for (const line of stdout.split(/\r?\n/)) {
15
+ const match = TSC_LINE.exec(line);
16
+ if (match) {
17
+ const [, path, lineNumber, column, severity, code, message] = match;
18
+ const start = { line: Number(lineNumber) - 1, character: Number(column) - 1 };
19
+ results.push({
20
+ file: resolve(cwd, path ?? ""),
21
+ diagnostic: {
22
+ range: { start, end: start },
23
+ severity: TSC_SEVERITY[severity ?? "error"] ?? 1,
24
+ code: code ?? "",
25
+ source: "bun check",
26
+ message: message ?? "",
27
+ },
28
+ });
29
+ continue;
30
+ }
31
+ const previous = results[results.length - 1];
32
+ if (previous !== undefined && /^\s+\S/.test(line)) {
33
+ previous.diagnostic.message = `${previous.diagnostic.message}\n${line.trim()}`;
34
+ }
35
+ }
36
+ return results;
37
+ }
38
+
39
+ interface EslintJsonMessage {
40
+ readonly ruleId?: string | null;
41
+ readonly severity?: number;
42
+ readonly message?: string;
43
+ readonly line?: number;
44
+ readonly column?: number;
45
+ readonly endLine?: number;
46
+ readonly endColumn?: number;
47
+ }
48
+
49
+ export function parseBunLintJson(stdout: string): FileDiagnostic[] | undefined {
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(stdout);
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ if (!Array.isArray(parsed)) return undefined;
57
+ const results: FileDiagnostic[] = [];
58
+ for (const entry of parsed) {
59
+ if (entry === null || typeof entry !== "object") continue;
60
+ const filePath = (entry as { filePath?: unknown }).filePath;
61
+ const messages = (entry as { messages?: unknown }).messages;
62
+ if (typeof filePath !== "string" || !Array.isArray(messages)) continue;
63
+ for (const raw of messages as EslintJsonMessage[]) {
64
+ const start = { line: Math.max(0, (raw.line ?? 1) - 1), character: Math.max(0, (raw.column ?? 1) - 1) };
65
+ const end = {
66
+ line: raw.endLine === undefined ? start.line : Math.max(0, raw.endLine - 1),
67
+ character: raw.endColumn === undefined ? start.character : Math.max(0, raw.endColumn - 1),
68
+ };
69
+ results.push({
70
+ file: filePath,
71
+ diagnostic: {
72
+ range: { start, end },
73
+ severity: raw.severity === 2 ? 1 : 2,
74
+ ...(raw.ruleId ? { code: raw.ruleId } : {}),
75
+ source: "bun lint",
76
+ message: raw.message ?? "",
77
+ },
78
+ });
79
+ }
80
+ }
81
+ return results;
82
+ }
@@ -0,0 +1,170 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { parseBunCheckOutput, parseBunLintJson } from "./bun-command-output.js";
6
+ import {
7
+ __resetBunCommandProbeCacheForTests,
8
+ bunHasCommand,
9
+ findBunProjectRoot,
10
+ projectShadowsBunCommand,
11
+ resolveBunProjectCommand,
12
+ } from "./bun-project-checks.js";
13
+ import { createFakeBun, createProject, removeFakeBunFixtures } from "./fake-bun-test-support.js";
14
+
15
+ afterEach(() => {
16
+ __resetBunCommandProbeCacheForTests();
17
+ removeFakeBunFixtures();
18
+ });
19
+
20
+ describe("findBunProjectRoot", () => {
21
+ it("#given a bun.lock, a bunfig.toml or a bun packageManager #when looked up from a nested directory #then that project root is found", () => {
22
+ for (const marker of [{ "bun.lock": "" }, { "bunfig.toml": "" }, { "package.json": '{"packageManager":"bun@1.4.3"}' }]) {
23
+ const root = createProject({ ...marker, "src/deep/a.ts": "" });
24
+ expect(findBunProjectRoot(join(root, "src", "deep"))).toBe(root);
25
+ }
26
+ });
27
+
28
+ it("#given an npm project inside a repository #when looked up #then the walk stops at the repository boundary", () => {
29
+ const root = createProject({ "package.json": '{"packageManager":"npm@11.0.0"}', "package-lock.json": "{}", "src/a.ts": "" });
30
+ expect(findBunProjectRoot(join(root, "src"))).toBeUndefined();
31
+ });
32
+
33
+ it("#given a bunfig.toml in the home directory and no repository #when looked up below home #then it is not a Bun project", () => {
34
+ const home = createProject({ "bunfig.toml": "", "work/src/a.ts": "" }, { git: false });
35
+ expect(findBunProjectRoot(join(home, "work", "src"), home)).toBeUndefined();
36
+ });
37
+ });
38
+
39
+ describe("projectShadowsBunCommand", () => {
40
+ it("#given a package.json script named like the command #when checked #then the built-in command is shadowed", () => {
41
+ const root = createProject({ "bun.lock": "", "package.json": '{"scripts":{"check":"tsc","lint":"eslint ."}}' });
42
+ expect(projectShadowsBunCommand("check", root)).toBe(true);
43
+ expect(projectShadowsBunCommand("lint", root)).toBe(true);
44
+ expect(projectShadowsBunCommand("format", root)).toBe(false);
45
+ });
46
+
47
+ it("#given a lint.ts file #when checked #then lint is shadowed but check is not", () => {
48
+ const root = createProject({ "bun.lock": "", "lint.ts": "", "check.ts": "" });
49
+ expect(projectShadowsBunCommand("lint", root)).toBe(true);
50
+ expect(projectShadowsBunCommand("check", root)).toBe(false);
51
+ });
52
+
53
+ it("#given a plain lint/ or format/ folder #when checked #then it does not shadow, but one with an index file does", () => {
54
+ const plain = createProject({ "bun.lock": "", "lint/rules.md": "", "format/notes.txt": "" });
55
+ expect(projectShadowsBunCommand("lint", plain)).toBe(false);
56
+ expect(projectShadowsBunCommand("format", plain)).toBe(false);
57
+ const runnable = createProject({ "bun.lock": "", "lint/index.ts": "" });
58
+ expect(projectShadowsBunCommand("lint", runnable)).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("bunHasCommand", () => {
63
+ it("#given a bun without lint #when probed #then only the commands whose --help exits 0 are available", async () => {
64
+ const bun = createFakeBun({ commands: ["check"] });
65
+ expect(await bunHasCommand(bun.path, "check")).toBe(true);
66
+ expect(await bunHasCommand(bun.path, "lint")).toBe(false);
67
+ expect(await bunHasCommand(bun.path, "format")).toBe(false);
68
+ });
69
+
70
+ it("#given concurrent and repeated probes #when probed #then each command runs --help once, in an empty directory", async () => {
71
+ const bun = createFakeBun({ commands: ["check", "lint"] });
72
+ await Promise.all([bunHasCommand(bun.path, "lint"), bunHasCommand(bun.path, "lint")]);
73
+ await bunHasCommand(bun.path, "lint");
74
+ const calls = bun.calls();
75
+ expect(calls.map((call) => call.args)).toEqual([["lint", "--help"]]);
76
+ expect(calls[0]?.cwdEntries).toEqual([]);
77
+ });
78
+
79
+ it("#given a bun whose format answers --help #when its format --write leaves the file unchanged #then format counts as unavailable", async () => {
80
+ const silent = createFakeBun({ commands: ["check", "format"], formatWrites: false });
81
+ const writing = createFakeBun({ commands: ["check", "format"], formatWrites: true });
82
+ expect(await bunHasCommand(silent.path, "format")).toBe(false);
83
+ expect(await bunHasCommand(writing.path, "format")).toBe(true);
84
+ expect(writing.calls().map((call) => call.args)).toEqual([
85
+ ["format", "--help"],
86
+ ["format", "--write", "probe.ts"],
87
+ ]);
88
+ });
89
+
90
+ it("#given a probe that times out #when probed again #then the timeout is not cached as absent and the retry answers", async () => {
91
+ const release = join(createProject({}), "release");
92
+ const bun = createFakeBun({ commands: ["check", "lint"], hangHelpUntil: release });
93
+ expect(await bunHasCommand(bun.path, "lint", 500)).toBe(false);
94
+ writeFileSync(release, "");
95
+ expect(await bunHasCommand(bun.path, "lint")).toBe(true);
96
+ }, 30_000);
97
+
98
+ it("#given a bun wrapper that starts bun as a child #when the probe times out #then the probe settles near its deadline", async () => {
99
+ const release = join(createProject({}), "release");
100
+ const bun = createFakeBun({ commands: ["check", "lint"], hangHelpUntil: release, wrapperForks: true });
101
+ const started = performance.now();
102
+ expect(await bunHasCommand(bun.path, "lint", 500)).toBe(false);
103
+ expect(performance.now() - started).toBeLessThan(5_000);
104
+ writeFileSync(release, "");
105
+ }, 30_000);
106
+
107
+ it("#given the bun binary is replaced in place #when probed again #then the new binary is probed", async () => {
108
+ const bun = createFakeBun({ commands: ["check"] });
109
+ expect(await bunHasCommand(bun.path, "lint")).toBe(false);
110
+ bun.upgrade({ commands: ["check", "lint"] });
111
+ expect(await bunHasCommand(bun.path, "lint")).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("resolveBunProjectCommand", () => {
116
+ it("#given a non-Bun project #when resolved #then bun is never looked up", async () => {
117
+ const root = createProject({ "package.json": "{}", "src/a.ts": "" });
118
+ let lookups = 0;
119
+ expect(await resolveBunProjectCommand("check", root, { resolveBun: () => (lookups++, "/unused/bun") })).toBeUndefined();
120
+ expect(lookups).toBe(0);
121
+ });
122
+
123
+ it("#given a Bun project and a bun with check #when resolved #then that bun is returned", async () => {
124
+ const root = createProject({ "bun.lock": "" });
125
+ const bun = createFakeBun({ commands: ["check"] });
126
+ expect(await resolveBunProjectCommand("check", root, { resolveBun: () => bun.path })).toBe(bun.path);
127
+ expect(await resolveBunProjectCommand("lint", root, { resolveBun: () => bun.path })).toBeUndefined();
128
+ });
129
+ });
130
+
131
+ describe("bun command output", () => {
132
+ it("#given tsc-style bun check output #when parsed #then diagnostics carry file, 0-based position, code and continuation lines", () => {
133
+ const cwd = createProject({});
134
+ const parsed = parseBunCheckOutput(
135
+ "src/x.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.\n The expected type comes from here.\n",
136
+ cwd,
137
+ );
138
+ expect(parsed).toEqual([
139
+ {
140
+ file: join(cwd, "src", "x.ts"),
141
+ diagnostic: {
142
+ range: { start: { line: 2, character: 6 }, end: { line: 2, character: 6 } },
143
+ severity: 1,
144
+ code: "TS2322",
145
+ source: "bun check",
146
+ message: "Type 'string' is not assignable to type 'number'.\nThe expected type comes from here.",
147
+ },
148
+ },
149
+ ]);
150
+ });
151
+
152
+ it("#given ESLint JSON from bun lint #when parsed #then errors and warnings map to LSP severities", () => {
153
+ const parsed = parseBunLintJson(
154
+ JSON.stringify([
155
+ {
156
+ filePath: "/p/a.ts",
157
+ messages: [
158
+ { ruleId: "prefer-const", severity: 2, message: "Use const", line: 3, column: 7, endLine: 3, endColumn: 12 },
159
+ { ruleId: null, severity: 1, message: "Unused directive", line: 1, column: 1 },
160
+ ],
161
+ },
162
+ ]),
163
+ );
164
+ expect(parsed?.map(({ file, diagnostic }) => [file, diagnostic.severity, diagnostic.code, diagnostic.range.start])).toEqual([
165
+ ["/p/a.ts", 1, "prefer-const", { line: 2, character: 6 }],
166
+ ["/p/a.ts", 2, undefined, { line: 0, character: 0 }],
167
+ ]);
168
+ expect(parseBunLintJson("not json")).toBeUndefined();
169
+ });
170
+ });
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createSpawnCommand, killProcessTree } from "./process.js";
6
+ import { resolveServerBinary } from "./server-installation.js";
7
+
8
+ export type BunProjectCommand = "check" | "lint" | "format";
9
+
10
+ export interface BunProjectChecksOptions {
11
+ readonly resolveBun?: (cwd: string) => string | null;
12
+ readonly homeDir?: string;
13
+ }
14
+
15
+ export interface BunCommandRun {
16
+ readonly exitCode: number | null;
17
+ readonly stdout: string;
18
+ readonly stderr: string;
19
+ }
20
+
21
+ const BUN_LOCKFILES = ["bun.lock", "bun.lockb", "bunfig.toml"];
22
+ const RUNNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".cts", ".cjs", ".js", ".mjs", ".mts"];
23
+ const BIN_SUFFIXES = process.platform === "win32" ? [".exe", ".bunx", ".cmd", ""] : [""];
24
+ const PROBE_TIMEOUT_MS = 10_000;
25
+ const UNFORMATTED_PROBE = "const a=1\n";
26
+
27
+ const probeCache = new Map<string, Promise<boolean>>();
28
+
29
+ export function __resetBunCommandProbeCacheForTests(): void {
30
+ probeCache.clear();
31
+ }
32
+
33
+ // The walk stops at the repository boundary, and never reads the home directory or anything above
34
+ // it, so a stray file there does not turn every directory outside a repository into a Bun project.
35
+ export function findBunProjectRoot(start: string, home: string = homedir()): string | undefined {
36
+ const stopAt = resolve(home);
37
+ let dir = resolve(start);
38
+ for (;;) {
39
+ if (dir === stopAt) return undefined;
40
+ if (BUN_LOCKFILES.some((name) => existsSync(join(dir, name)))) return dir;
41
+ if (packageManagerIsBun(join(dir, "package.json"))) return dir;
42
+ if (existsSync(join(dir, ".git"))) return undefined;
43
+ const parent = dirname(dir);
44
+ if (parent === dir) return undefined;
45
+ dir = parent;
46
+ }
47
+ }
48
+
49
+ function packageManagerIsBun(packageJsonPath: string): boolean {
50
+ const manifest = readJsonObject(packageJsonPath);
51
+ const packageManager = manifest?.["packageManager"];
52
+ return typeof packageManager === "string" && packageManager.startsWith("bun@");
53
+ }
54
+
55
+ function readJsonObject(path: string): Record<string, unknown> | undefined {
56
+ if (!existsSync(path)) return undefined;
57
+ try {
58
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
59
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
60
+ ? (parsed as Record<string, unknown>)
61
+ : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function isFile(path: string): boolean {
68
+ try {
69
+ return statSync(path).isFile();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function isDirectory(path: string): boolean {
76
+ try {
77
+ return statSync(path).isDirectory();
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Bun still runs a project's own script (nearest package.json) named check/lint/format, and for
84
+ // lint/format also what `bun <name>` would run as a file: `name` or `name.<ext>`, a `name/` directory
85
+ // with a package.json or an index file, or an executable a package installed in node_modules/.bin.
86
+ export function projectShadowsBunCommand(command: BunProjectCommand, cwd: string): boolean {
87
+ const packageJsonDir = nearestPackageJsonDir(cwd);
88
+ if (packageJsonDir !== undefined) {
89
+ const scripts = readJsonObject(join(packageJsonDir, "package.json"))?.["scripts"];
90
+ if (scripts !== null && typeof scripts === "object" && typeof (scripts as Record<string, unknown>)[command] === "string") {
91
+ return true;
92
+ }
93
+ }
94
+ if (command === "check") return false;
95
+ const base = join(cwd, command);
96
+ if (isFile(base) || RUNNABLE_EXTENSIONS.some((extension) => isFile(`${base}${extension}`))) return true;
97
+ if (isDirectory(base) && (isFile(join(base, "package.json")) || RUNNABLE_EXTENSIONS.some((extension) => isFile(join(base, `index${extension}`))))) {
98
+ return true;
99
+ }
100
+ let dir = resolve(cwd);
101
+ for (;;) {
102
+ if (BIN_SUFFIXES.some((suffix) => existsSync(join(dir, "node_modules", ".bin", `${command}${suffix}`)))) return true;
103
+ const parent = dirname(dir);
104
+ if (parent === dir) return false;
105
+ dir = parent;
106
+ }
107
+ }
108
+
109
+ function nearestPackageJsonDir(start: string): string | undefined {
110
+ let dir = resolve(start);
111
+ for (;;) {
112
+ if (existsSync(join(dir, "package.json"))) return dir;
113
+ const parent = dirname(dir);
114
+ if (parent === dir) return undefined;
115
+ dir = parent;
116
+ }
117
+ }
118
+
119
+ function binaryIdentity(bunPath: string): string {
120
+ try {
121
+ const stats = statSync(bunPath);
122
+ return `${stats.mtimeMs}:${stats.size}`;
123
+ } catch {
124
+ return "missing";
125
+ }
126
+ }
127
+
128
+ // Feature detection by exit code, in an empty directory: next to a same-named project script an
129
+ // older Bun runs that script for `--help` and exits 0. Asynchronous, so a probe never blocks the
130
+ // event loop; cached per binary and command, and re-probed when the binary file changes. Only a
131
+ // definite answer is kept: a probe that timed out or could not spawn is retried on the next call.
132
+ export function bunHasCommand(
133
+ bunPath: string,
134
+ command: BunProjectCommand,
135
+ timeoutMs: number = PROBE_TIMEOUT_MS,
136
+ ): Promise<boolean> {
137
+ const key = `${bunPath}\0${binaryIdentity(bunPath)}\0${command}`;
138
+ const cached = probeCache.get(key);
139
+ if (cached !== undefined) return cached;
140
+ const probe = probeOnce(bunPath, command, timeoutMs).then((answer) => {
141
+ if (answer === undefined) probeCache.delete(key);
142
+ return answer === true;
143
+ });
144
+ probeCache.set(key, probe);
145
+ return probe;
146
+ }
147
+
148
+ // `format` must also prove it rewrites a file in place: a bun whose `format --write` printed to stdout
149
+ // instead, or ignored the flag, would leave every file unchanged and silently stop formatting.
150
+ async function probeOnce(bunPath: string, command: BunProjectCommand, timeoutMs: number): Promise<boolean | undefined> {
151
+ const emptyDir = mkdtempSync(join(tmpdir(), "omo-bun-probe-"));
152
+ const timeout = AbortSignal.timeout(timeoutMs);
153
+ try {
154
+ const help = await runBunCommand(bunPath, [command, "--help"], emptyDir, timeout);
155
+ if (help.exitCode === null) return undefined;
156
+ if (help.exitCode !== 0) return false;
157
+ if (command !== "format") return true;
158
+ const probeFile = join(emptyDir, "probe.ts");
159
+ writeFileSync(probeFile, UNFORMATTED_PROBE);
160
+ const write = await runBunCommand(bunPath, ["format", "--write", "probe.ts"], emptyDir, timeout);
161
+ if (write.exitCode === null) return undefined;
162
+ return write.exitCode === 0 && readFileSync(probeFile, "utf8") !== UNFORMATTED_PROBE;
163
+ } catch {
164
+ return undefined;
165
+ } finally {
166
+ rmSync(emptyDir, { recursive: true, force: true });
167
+ }
168
+ }
169
+
170
+ export async function resolveBunProjectCommand(
171
+ command: BunProjectCommand,
172
+ cwd: string,
173
+ options: BunProjectChecksOptions = {},
174
+ ): Promise<string | undefined> {
175
+ if (findBunProjectRoot(cwd, options.homeDir) === undefined) return undefined;
176
+ if (projectShadowsBunCommand(command, cwd)) return undefined;
177
+ const bunPath = (options.resolveBun ?? defaultResolveBun)(cwd);
178
+ if (!bunPath) return undefined;
179
+ return (await bunHasCommand(bunPath, command)) ? bunPath : undefined;
180
+ }
181
+
182
+ function defaultResolveBun(cwd: string): string | null {
183
+ return resolveServerBinary(["bun"], cwd);
184
+ }
185
+
186
+ export function runBunCommand(bunPath: string, args: readonly string[], cwd: string, signal?: AbortSignal): Promise<BunCommandRun> {
187
+ signal?.throwIfAborted();
188
+ const prepared = createSpawnCommand([bunPath, ...args]);
189
+ return new Promise((resolvePromise, reject) => {
190
+ const child = spawn(prepared.command, prepared.args, {
191
+ cwd,
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ shell: prepared.shell,
194
+ windowsHide: true,
195
+ detached: process.platform !== "win32",
196
+ });
197
+ const stdout: Buffer[] = [];
198
+ const stderr: Buffer[] = [];
199
+ // A wrapper (a .cmd shim through cmd.exe on Windows, a non-exec sh script elsewhere) leaves bun holding
200
+ // the pipes when only the direct child dies, so an aborted probe would outlive its timeout. Kill the
201
+ // whole tree: taskkill /T on Windows, the process group (hence `detached`) elsewhere.
202
+ const onAbort = () => killProcessTree(child, "SIGKILL");
203
+ signal?.addEventListener("abort", onAbort, { once: true });
204
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
205
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
206
+ child.once("error", (error) => {
207
+ signal?.removeEventListener("abort", onAbort);
208
+ reject(error);
209
+ });
210
+ child.once("close", (exitCode) => {
211
+ signal?.removeEventListener("abort", onAbort);
212
+ if (signal?.aborted) {
213
+ reject(signal.reason);
214
+ return;
215
+ }
216
+ resolvePromise({
217
+ exitCode,
218
+ stdout: Buffer.concat(stdout).toString("utf8"),
219
+ stderr: Buffer.concat(stderr).toString("utf8"),
220
+ });
221
+ });
222
+ });
223
+ }
@@ -0,0 +1,102 @@
1
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const createdDirs: string[] = [];
6
+
7
+ function createTempDir(prefix: string): string {
8
+ const dir = mkdtempSync(join(tmpdir(), prefix));
9
+ createdDirs.push(dir);
10
+ return dir;
11
+ }
12
+
13
+ export function removeFakeBunFixtures(): void {
14
+ for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
15
+ }
16
+
17
+ export interface FakeBunBehavior {
18
+ readonly commands: readonly ("check" | "lint" | "format")[];
19
+ readonly checkStdout?: string;
20
+ readonly checkExitCode?: number;
21
+ readonly lintStdout?: string;
22
+ readonly lintExitCode?: number;
23
+ readonly formatWrites?: boolean;
24
+ readonly hangHelpUntil?: string;
25
+ readonly wrapperForks?: boolean;
26
+ }
27
+
28
+ export interface FakeBunCall {
29
+ readonly args: readonly string[];
30
+ readonly cwd: string;
31
+ readonly cwdEntries: readonly string[];
32
+ }
33
+
34
+ export interface FakeBun {
35
+ readonly path: string;
36
+ calls(): FakeBunCall[];
37
+ upgrade(behavior: FakeBunBehavior): void;
38
+ }
39
+
40
+ export function createFakeBun(behavior: FakeBunBehavior): FakeBun {
41
+ const dir = createTempDir("omo-fake-bun-");
42
+ const logPath = join(dir, "calls.jsonl");
43
+ const binDir = join(dir, "bin");
44
+ mkdirSync(binDir);
45
+ const path = process.platform === "win32" ? join(binDir, "bun.cmd") : join(binDir, "bun");
46
+ let version = 0;
47
+ const install = (next: FakeBunBehavior) => {
48
+ version += 1;
49
+ const scriptPath = join(dir, `fake-bun-${version}.mjs`);
50
+ writeFakeBunScript(scriptPath, logPath, next);
51
+ if (process.platform === "win32") {
52
+ writeFileSync(path, `@"${process.execPath}" "${scriptPath}" %*\r\n`);
53
+ } else {
54
+ const launch = `"${process.execPath}" "${scriptPath}" "$@"`;
55
+ writeFileSync(path, next.wrapperForks ? `#!/bin/sh\n${launch}\n` : `#!/bin/sh\nexec ${launch}\n`);
56
+ chmodSync(path, 0o755);
57
+ }
58
+ };
59
+ install(behavior);
60
+ return {
61
+ path,
62
+ upgrade: install,
63
+ calls: () =>
64
+ existsSync(logPath)
65
+ ? readFileSync(logPath, "utf8")
66
+ .split("\n")
67
+ .filter((line) => line.length > 0)
68
+ .map((line): FakeBunCall => JSON.parse(line))
69
+ : [],
70
+ };
71
+ }
72
+
73
+ function writeFakeBunScript(scriptPath: string, logPath: string, behavior: FakeBunBehavior): void {
74
+ writeFileSync(
75
+ scriptPath,
76
+ [
77
+ 'import { appendFileSync, existsSync, readdirSync, writeFileSync } from "node:fs";',
78
+ `const behavior = ${JSON.stringify(behavior)};`,
79
+ 'if (behavior.hangHelpUntil && process.argv.includes("--help") && !existsSync(behavior.hangHelpUntil)) await new Promise((r) => setTimeout(r, 60_000));',
80
+ "const args = process.argv.slice(2);",
81
+ `appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ args, cwd: process.cwd(), cwdEntries: readdirSync(process.cwd()) }) + "\\n");`,
82
+ "const [command] = args;",
83
+ "if (!behavior.commands.includes(command)) process.exit(1);",
84
+ 'if (args.includes("--help")) process.exit(0);',
85
+ 'if (command === "check") { process.stdout.write(behavior.checkStdout ?? ""); process.exit(behavior.checkExitCode ?? 0); }',
86
+ 'if (command === "lint") { process.stdout.write(behavior.lintStdout ?? "[]"); process.exit(behavior.lintExitCode ?? 0); }',
87
+ 'if (command === "format" && behavior.formatWrites && args.includes("--write")) writeFileSync(args[args.length - 1], "const a = 1;\\n");',
88
+ "process.exit(0);",
89
+ ].join("\n"),
90
+ );
91
+ }
92
+
93
+ export function createProject(files: Record<string, string>, options: { readonly git?: boolean } = {}): string {
94
+ const root = createTempDir("omo-bun-project-");
95
+ if (options.git !== false) mkdirSync(join(root, ".git"));
96
+ for (const [relativePath, content] of Object.entries(files)) {
97
+ const target = join(root, relativePath);
98
+ mkdirSync(join(target, ".."), { recursive: true });
99
+ writeFileSync(target, content);
100
+ }
101
+ return root;
102
+ }
@@ -102,7 +102,7 @@ function wrap(proc: ChildProcess): SpawnedProcess {
102
102
  };
103
103
  }
104
104
 
105
- function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
105
+ export function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
106
106
  if (process.platform === "win32" && proc.pid) {
107
107
  const result = spawnSync("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
108
108
  stdio: "ignore",
@@ -14,7 +14,8 @@ const expectedToolSurface = [
14
14
  {
15
15
  name: "diagnostics",
16
16
  title: "LSP Diagnostics",
17
- description: "Get errors, warnings, and hints for a source file or directory.",
17
+ description:
18
+ "Get errors, warnings, and hints for a source file or directory. In a Bun project, a TypeScript directory is typechecked with `bun check`, plus `bun lint` when the installed Bun has it.",
18
19
  inputSchema: {
19
20
  type: "object",
20
21
  properties: {