304 lines
8.5 KiB
TypeScript
304 lines
8.5 KiB
TypeScript
/**
|
|
* Bemol — Amazon's Brazil workspace → LSP bridge integration.
|
|
*
|
|
* Detects Brazil workspaces, runs bemol to generate LSP configs,
|
|
* manages a background bemol --watch process, and reads workspace
|
|
* root folders for multi-root LSP support.
|
|
*/
|
|
|
|
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { acquireLock, releaseLock, isLockedByOther } from "./locks.js";
|
|
|
|
export interface BemolStatus {
|
|
isBrazilWorkspace: boolean;
|
|
workspaceRoot: string | null;
|
|
bemolAvailable: boolean;
|
|
hasConfig: boolean;
|
|
watching: boolean;
|
|
workspaceRoots: string[];
|
|
}
|
|
|
|
export interface BemolRunResult {
|
|
success: boolean;
|
|
output: string;
|
|
duration: number;
|
|
}
|
|
|
|
export class BemolManager {
|
|
private watchProcess: ChildProcess | null = null;
|
|
private _workspaceRoot: string | null = null;
|
|
private _bemolAvailable: boolean | null = null;
|
|
private _bemolRan = false;
|
|
|
|
constructor(rootDir: string) {
|
|
this._workspaceRoot = BemolManager.findWorkspaceRoot(rootDir);
|
|
}
|
|
|
|
/** Whether the cwd is inside a Brazil workspace */
|
|
get isBrazilWorkspace(): boolean {
|
|
return this._workspaceRoot !== null;
|
|
}
|
|
|
|
/** The Brazil workspace root (directory containing packageInfo) */
|
|
get workspaceRoot(): string | null {
|
|
return this._workspaceRoot;
|
|
}
|
|
|
|
/** Whether bemol has already been run this session */
|
|
get bemolRan(): boolean {
|
|
return this._bemolRan;
|
|
}
|
|
|
|
/** Whether bemol --watch is running */
|
|
get isWatching(): boolean {
|
|
return this.watchProcess !== null && !this.watchProcess.killed;
|
|
}
|
|
|
|
/**
|
|
* Walk up from startDir looking for a `packageInfo` file.
|
|
* Returns the directory containing it, or null.
|
|
*/
|
|
static findWorkspaceRoot(startDir: string): string | null {
|
|
let dir = startDir;
|
|
// Walk up at most 20 levels to avoid infinite loops
|
|
for (let i = 0; i < 20; i++) {
|
|
const candidate = join(dir, "packageInfo");
|
|
try {
|
|
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
return dir;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break; // reached filesystem root
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Check if bemol is available on PATH */
|
|
static isBemolAvailable(): boolean {
|
|
try {
|
|
execSync("which bemol", { stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Check if bemol is available (cached) */
|
|
get bemolAvailable(): boolean {
|
|
if (this._bemolAvailable === null) {
|
|
this._bemolAvailable = BemolManager.isBemolAvailable();
|
|
}
|
|
return this._bemolAvailable;
|
|
}
|
|
|
|
/** Check if .bemol/ws_root_folders exists */
|
|
hasConfig(): boolean {
|
|
if (!this._workspaceRoot) return false;
|
|
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
|
|
return existsSync(foldersFile);
|
|
}
|
|
|
|
/**
|
|
* Read .bemol/ws_root_folders and return array of existing directory paths.
|
|
*/
|
|
getWorkspaceRoots(): string[] {
|
|
if (!this._workspaceRoot) return [];
|
|
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
|
|
try {
|
|
const content = readFileSync(foldersFile, "utf-8");
|
|
return content
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.filter((dir) => {
|
|
try {
|
|
return existsSync(dir) && statSync(dir).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get workspace folders formatted for LSP InitializeParams.
|
|
* Returns array of { uri, name } objects.
|
|
*/
|
|
getWorkspaceFolders(): { uri: string; name: string }[] {
|
|
const roots = this.getWorkspaceRoots();
|
|
if (roots.length === 0) return [];
|
|
return roots.map((dir) => ({
|
|
uri: pathToFileURL(dir).toString(),
|
|
name: dir.split("/").pop() ?? "package",
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Run `bemol --verbose` in the workspace root.
|
|
* Returns result with success status, output, and duration.
|
|
*/
|
|
async runBemol(): Promise<BemolRunResult> {
|
|
if (!this._workspaceRoot) {
|
|
return { success: false, output: "Not in a Brazil workspace", duration: 0 };
|
|
}
|
|
if (!this.bemolAvailable) {
|
|
return { success: false, output: "bemol is not installed (try: toolbox install bemol)", duration: 0 };
|
|
}
|
|
|
|
const start = Date.now();
|
|
|
|
return new Promise((resolve) => {
|
|
const child = spawn("bemol", ["--verbose"], {
|
|
cwd: this._workspaceRoot!,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
const chunks: Buffer[] = [];
|
|
child.stdout?.on("data", (data) => chunks.push(data));
|
|
child.stderr?.on("data", (data) => chunks.push(data));
|
|
|
|
// Timeout after 120s
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
resolve({
|
|
success: false,
|
|
output: "bemol timed out after 120 seconds",
|
|
duration: Date.now() - start,
|
|
});
|
|
}, 120_000);
|
|
|
|
child.on("error", (err) => {
|
|
clearTimeout(timer);
|
|
resolve({
|
|
success: false,
|
|
output: `bemol failed to start: ${err.message}`,
|
|
duration: Date.now() - start,
|
|
});
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
const output = Buffer.concat(chunks).toString();
|
|
this._bemolRan = true;
|
|
resolve({
|
|
success: code === 0,
|
|
output: output || (code === 0 ? "bemol completed successfully" : `bemol exited with code ${code}`),
|
|
duration: Date.now() - start,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ensure bemol has been run at least once this session.
|
|
* If configs exist already, skips. If another session is running bemol,
|
|
* waits briefly then checks again. If not, runs bemol.
|
|
* Returns true if configs are available after this call.
|
|
*/
|
|
async ensureBemolConfig(sessionId?: string): Promise<boolean> {
|
|
if (!this.isBrazilWorkspace) return false;
|
|
if (this.hasConfig()) {
|
|
this._bemolRan = true;
|
|
return true;
|
|
}
|
|
if (this._bemolRan) return this.hasConfig();
|
|
if (!this.bemolAvailable) return false;
|
|
|
|
// Check if another session is already running bemol
|
|
if (this._workspaceRoot && isLockedByOther(this._workspaceRoot, "bemol")) {
|
|
// Wait up to 60s for the other session to finish
|
|
for (let i = 0; i < 12; i++) {
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
if (this.hasConfig()) {
|
|
this._bemolRan = true;
|
|
return true;
|
|
}
|
|
if (!isLockedByOther(this._workspaceRoot!, "bemol")) break;
|
|
}
|
|
// If config appeared, use it. Otherwise fall through to run our own.
|
|
if (this.hasConfig()) {
|
|
this._bemolRan = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Acquire bemol lock
|
|
const sid = sessionId ?? `${process.pid}-${Date.now()}`;
|
|
if (this._workspaceRoot) {
|
|
acquireLock(this._workspaceRoot, "bemol", sid);
|
|
}
|
|
|
|
try {
|
|
const result = await this.runBemol();
|
|
return result.success && this.hasConfig();
|
|
} finally {
|
|
if (this._workspaceRoot) {
|
|
releaseLock(this._workspaceRoot, "bemol");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start `bemol --watch` as a background process.
|
|
*/
|
|
startWatch(): boolean {
|
|
if (!this._workspaceRoot || !this.bemolAvailable) return false;
|
|
if (this.isWatching) return true; // already running
|
|
|
|
this.watchProcess = spawn("bemol", ["--watch"], {
|
|
cwd: this._workspaceRoot,
|
|
stdio: ["ignore", "ignore", "ignore"],
|
|
detached: false,
|
|
});
|
|
|
|
this.watchProcess.on("error", () => {
|
|
this.watchProcess = null;
|
|
});
|
|
|
|
this.watchProcess.on("exit", () => {
|
|
this.watchProcess = null;
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Stop the background bemol --watch process */
|
|
stopWatch(): void {
|
|
if (this.watchProcess) {
|
|
this.watchProcess.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
if (this.watchProcess && !this.watchProcess.killed) {
|
|
this.watchProcess.kill("SIGKILL");
|
|
}
|
|
}, 2000);
|
|
this.watchProcess = null;
|
|
}
|
|
}
|
|
|
|
/** Get full status summary */
|
|
getStatus(): BemolStatus {
|
|
return {
|
|
isBrazilWorkspace: this.isBrazilWorkspace,
|
|
workspaceRoot: this._workspaceRoot,
|
|
bemolAvailable: this.bemolAvailable,
|
|
hasConfig: this.hasConfig(),
|
|
watching: this.isWatching,
|
|
workspaceRoots: this.getWorkspaceRoots(),
|
|
};
|
|
}
|
|
|
|
/** Shutdown: stop watch, clean up */
|
|
shutdown(): void {
|
|
this.stopWatch();
|
|
}
|
|
}
|