Add pi-tool-display (compact tool rendering, adaptive diffs) + pi-lsp-extension (LSP diagnostics/hover/definitions) — vendored from npm 0.5.0 / 1.3.0
This commit is contained in:
303
extensions/pi-lsp-extension/bemol-extension/bemol.ts
Normal file
303
extensions/pi-lsp-extension/bemol-extension/bemol.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
195
extensions/pi-lsp-extension/bemol-extension/index.ts
Normal file
195
extensions/pi-lsp-extension/bemol-extension/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* pi-lsp-bemol — Bemol workspace provider for pi-lsp-extension.
|
||||
*
|
||||
* Detects Amazon Brazil workspaces, runs bemol to generate LSP configs,
|
||||
* and registers a WorkspaceProvider with the LSP extension via pi.events.
|
||||
*
|
||||
* Install: place in ~/.pi/agent/extensions/pi-lsp-bemol/ or load with pi -e
|
||||
*
|
||||
* Requires: bemol on PATH (toolbox install bemol)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { join } from "node:path";
|
||||
import { BemolManager } from "./bemol.js";
|
||||
|
||||
/**
|
||||
* WorkspaceProvider interface — must match the one in pi-lsp-extension.
|
||||
* The LSP extension accepts any object with this shape via pi.events.
|
||||
*/
|
||||
interface WorkspaceProvider {
|
||||
readonly type: string;
|
||||
readonly workspaceRoot: string | null;
|
||||
readonly stateDir: string | null;
|
||||
getWorkspaceFolders(): { uri: string; name: string }[];
|
||||
ensureReady(sessionId?: string): Promise<boolean>;
|
||||
getStatusText(): string;
|
||||
shutdown(): void;
|
||||
}
|
||||
|
||||
class BemolWorkspaceProvider implements WorkspaceProvider {
|
||||
readonly type = "bemol";
|
||||
readonly manager: BemolManager;
|
||||
|
||||
constructor(manager: BemolManager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
get workspaceRoot(): string | null {
|
||||
return this.manager.workspaceRoot;
|
||||
}
|
||||
|
||||
get stateDir(): string | null {
|
||||
const root = this.manager.workspaceRoot;
|
||||
return root ? join(root, ".bemol") : null;
|
||||
}
|
||||
|
||||
getWorkspaceFolders(): { uri: string; name: string }[] {
|
||||
return this.manager.getWorkspaceFolders();
|
||||
}
|
||||
|
||||
async ensureReady(sessionId?: string): Promise<boolean> {
|
||||
return this.manager.ensureBemolConfig(sessionId);
|
||||
}
|
||||
|
||||
getStatusText(): string {
|
||||
const hasConfig = this.manager.hasConfig();
|
||||
const hasBemol = this.manager.bemolAvailable;
|
||||
if (hasConfig) return "Brazil workspace (bemol config found)";
|
||||
if (hasBemol) return "Brazil workspace (bemol will run on first LSP use)";
|
||||
return "Brazil workspace (bemol not installed)";
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.manager.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
export default function bemolExtension(pi: ExtensionAPI) {
|
||||
// Detect and register at factory time (synchronous, before any session_start).
|
||||
// This ensures the provider is available before autoStart kicks in.
|
||||
const bemol = new BemolManager(process.cwd());
|
||||
let provider: BemolWorkspaceProvider | null = null;
|
||||
|
||||
if (bemol.isBrazilWorkspace) {
|
||||
provider = new BemolWorkspaceProvider(bemol);
|
||||
// Store on event bus so the LSP extension can find it regardless of load order
|
||||
(pi.events as any)["lsp:workspace-provider"] = provider;
|
||||
pi.events.emit("lsp:register-workspace-provider", provider);
|
||||
}
|
||||
|
||||
// Register /bemol command and update status once we have UI context
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (!provider) return;
|
||||
|
||||
// Re-detect with ctx.cwd in case it differs from process.cwd()
|
||||
if (ctx.cwd !== process.cwd()) {
|
||||
const freshBemol = new BemolManager(ctx.cwd);
|
||||
if (freshBemol.isBrazilWorkspace) {
|
||||
provider = new BemolWorkspaceProvider(freshBemol);
|
||||
pi.events.emit("lsp:register-workspace-provider", provider);
|
||||
} else {
|
||||
provider = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("bemol", {
|
||||
description: "Manage bemol: /bemol [run|watch|stop|status]",
|
||||
handler: async (args, cmdCtx) => {
|
||||
if (!provider) return;
|
||||
const mgr = provider.manager;
|
||||
const subcommand = args?.trim().toLowerCase() || "run";
|
||||
|
||||
switch (subcommand) {
|
||||
case "run": {
|
||||
if (!mgr.bemolAvailable) {
|
||||
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
|
||||
return;
|
||||
}
|
||||
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("warning", "LSP: running bemol..."));
|
||||
cmdCtx.ui.notify("Running bemol --verbose...", "info");
|
||||
const result = await mgr.runBemol();
|
||||
if (result.success) {
|
||||
const roots = mgr.getWorkspaceRoots();
|
||||
cmdCtx.ui.notify(
|
||||
`bemol completed in ${(result.duration / 1000).toFixed(1)}s\n${roots.length} package root(s) configured`,
|
||||
"info"
|
||||
);
|
||||
} else {
|
||||
cmdCtx.ui.notify(`bemol failed:\n${result.output.slice(0, 500)}`, "error");
|
||||
}
|
||||
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("accent", "LSP: Brazil workspace (bemol done)"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "watch": {
|
||||
if (!mgr.bemolAvailable) {
|
||||
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
|
||||
return;
|
||||
}
|
||||
if (mgr.isWatching) {
|
||||
cmdCtx.ui.notify("bemol --watch is already running", "info");
|
||||
return;
|
||||
}
|
||||
const started = mgr.startWatch();
|
||||
if (started) {
|
||||
cmdCtx.ui.notify("Started bemol --watch in background", "info");
|
||||
cmdCtx.ui.setStatus("bemol", cmdCtx.ui.theme.fg("accent", "bemol: watching"));
|
||||
} else {
|
||||
cmdCtx.ui.notify("Failed to start bemol --watch", "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop": {
|
||||
if (!mgr.isWatching) {
|
||||
cmdCtx.ui.notify("bemol --watch is not running", "info");
|
||||
return;
|
||||
}
|
||||
mgr.stopWatch();
|
||||
cmdCtx.ui.notify("Stopped bemol --watch", "info");
|
||||
cmdCtx.ui.setStatus("bemol", "");
|
||||
break;
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const status = mgr.getStatus();
|
||||
const lines = [
|
||||
`Brazil workspace: ${status.isBrazilWorkspace ? "yes" : "no"}`,
|
||||
`Workspace root: ${status.workspaceRoot ?? "N/A"}`,
|
||||
`bemol available: ${status.bemolAvailable ? "yes" : "no"}`,
|
||||
`bemol config: ${status.hasConfig ? "yes" : "missing"}`,
|
||||
`bemol watch: ${status.watching ? "running" : "stopped"}`,
|
||||
`Package roots: ${status.workspaceRoots.length}`,
|
||||
];
|
||||
if (status.workspaceRoots.length > 0) {
|
||||
const shown = status.workspaceRoots.slice(0, 10);
|
||||
for (const root of shown) {
|
||||
lines.push(` ${root}`);
|
||||
}
|
||||
if (status.workspaceRoots.length > 10) {
|
||||
lines.push(` ... and ${status.workspaceRoots.length - 10} more`);
|
||||
}
|
||||
}
|
||||
cmdCtx.ui.notify(lines.join("\n"), "info");
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
cmdCtx.ui.notify(
|
||||
"Usage: /bemol [run|watch|stop|status]\n run — run bemol --verbose\n watch — start bemol --watch\n stop — stop bemol --watch\n status — show bemol status",
|
||||
"info"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (provider) {
|
||||
provider.shutdown();
|
||||
provider = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
144
extensions/pi-lsp-extension/bemol-extension/locks.ts
Normal file
144
extensions/pi-lsp-extension/bemol-extension/locks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Workspace Lock — cross-session coordination for bemol runs.
|
||||
*
|
||||
* Uses PID-based lockfiles in `.bemol/locks/` to prevent multiple pi sessions
|
||||
* from running bemol simultaneously in the same workspace.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, constants as fsConstants } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface LockInfo {
|
||||
pid: number;
|
||||
startTime: number;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still alive.
|
||||
*/
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_LOCK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Try to acquire a named lock for this workspace.
|
||||
* Uses O_CREAT|O_EXCL for atomic creation to avoid TOCTOU races.
|
||||
* Returns true if acquired, false if another live session holds it.
|
||||
*/
|
||||
export function acquireLock(workspaceRoot: string, name: string, sessionId: string): boolean {
|
||||
const locksDir = join(workspaceRoot, ".bemol", "locks");
|
||||
const lockFile = join(locksDir, `${name}.lock`);
|
||||
|
||||
// Check existing lock — clean up stale locks from dead processes or old age
|
||||
const existing = readLock(workspaceRoot, name);
|
||||
if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
|
||||
// Process is alive, but check if the lock is older than 5 minutes (stale)
|
||||
try {
|
||||
const stat = statSync(lockFile);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
if (ageMs > STALE_LOCK_THRESHOLD_MS) {
|
||||
// Lock is stale — remove it even though the process is alive
|
||||
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
||||
} else {
|
||||
return false; // another live session holds a fresh lock
|
||||
}
|
||||
} catch {
|
||||
return false; // can't stat, assume lock is valid
|
||||
}
|
||||
}
|
||||
|
||||
// If stale lock exists, remove it first
|
||||
if (existing) {
|
||||
try { unlinkSync(lockFile); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Acquire atomically using O_CREAT|O_EXCL (fails if file already exists)
|
||||
try {
|
||||
mkdirSync(locksDir, { recursive: true });
|
||||
const info: LockInfo = { pid: process.pid, startTime: Date.now(), sessionId };
|
||||
const content = JSON.stringify(info);
|
||||
|
||||
// O_WRONLY | O_CREAT | O_EXCL — atomic: fails with EEXIST if another process created it first
|
||||
const fd = openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
// Another process acquired the lock between our unlink and open — that's fine
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a lock owned by this process.
|
||||
*/
|
||||
export function releaseLock(workspaceRoot: string, name: string): void {
|
||||
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
|
||||
try {
|
||||
const existing = readLock(workspaceRoot, name);
|
||||
// Only delete if we own it
|
||||
if (existing && existing.pid === process.pid) {
|
||||
unlinkSync(lockFile);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current lock holder, or null if no lock / stale lock.
|
||||
*/
|
||||
export function readLock(workspaceRoot: string, name: string): LockInfo | null {
|
||||
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
|
||||
try {
|
||||
if (!existsSync(lockFile)) return null;
|
||||
const content = readFileSync(lockFile, "utf-8");
|
||||
const info = JSON.parse(content) as LockInfo;
|
||||
if (!info.pid || !info.startTime) return null;
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a lock is held by another live process (not us).
|
||||
*/
|
||||
export function isLockedByOther(workspaceRoot: string, name: string): boolean {
|
||||
const info = readLock(workspaceRoot, name);
|
||||
if (!info) return false;
|
||||
if (info.pid === process.pid) return false;
|
||||
return isProcessAlive(info.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all locks owned by this process in the workspace.
|
||||
*/
|
||||
export function releaseAllLocks(workspaceRoot: string): void {
|
||||
const locksDir = join(workspaceRoot, ".bemol", "locks");
|
||||
try {
|
||||
if (!existsSync(locksDir)) return;
|
||||
const files: string[] = readdirSync(locksDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".lock")) continue;
|
||||
const name = file.replace(/\.lock$/, "");
|
||||
releaseLock(workspaceRoot, name);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user