routstrd 0.2.6 → 0.2.9

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,304 @@
1
+ import {
2
+ callDaemon,
3
+ loadConfig,
4
+ getDaemonBaseUrl,
5
+ ensureDaemonRunning,
6
+ } from "./daemon-client";
7
+ import {
8
+ parseSecretKey,
9
+ npubFromSecretKey,
10
+ } from "./nip98";
11
+ import { type RoutstrdConfig } from "./config";
12
+ import { logger } from "./logger";
13
+ import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry";
14
+
15
+ export function getNpubSuffix(config: RoutstrdConfig): string | null {
16
+ if (!config.daemonUrl || !config.nsec) return null;
17
+ try {
18
+ const secretKey = parseSecretKey(config.nsec);
19
+ const npub = npubFromSecretKey(secretKey);
20
+ return npub.slice(-7);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Add suffix to a client ID.
28
+ */
29
+ export function addSuffixToId(id: string, suffix: string): string {
30
+ return `${id}-${suffix}`;
31
+ }
32
+
33
+ /**
34
+ * Remove suffix from a client ID if present.
35
+ */
36
+ export function removeSuffixFromId(id: string, suffix: string): string {
37
+ const suffixStr = `-${suffix}`;
38
+ if (id.endsWith(suffixStr)) {
39
+ return id.slice(0, -suffixStr.length);
40
+ }
41
+ return id;
42
+ }
43
+
44
+ export interface ClientEntry {
45
+ clientId: string;
46
+ name: string;
47
+ apiKey: string;
48
+ createdAt: number;
49
+ lastUsed?: number | null;
50
+ }
51
+
52
+ export interface DaemonClient {
53
+ id: string;
54
+ name: string;
55
+ apiKey: string;
56
+ createdAt: number;
57
+ lastUsed?: number | null;
58
+ }
59
+
60
+ /**
61
+ * Read the clients list directly from the SDK store.
62
+ * Use this when running inside the daemon (local mode).
63
+ */
64
+ export function getClientsFromStore(store: { getState(): any }): ClientEntry[] {
65
+ const state = store.getState();
66
+ const clientIds = state.clientIds || [];
67
+ return clientIds.map(
68
+ (c: {
69
+ clientId: string;
70
+ name: string;
71
+ apiKey: string;
72
+ createdAt: number;
73
+ lastUsed?: number | null;
74
+ }) => ({
75
+ clientId: c.clientId,
76
+ name: c.name,
77
+ apiKey: c.apiKey,
78
+ createdAt: c.createdAt,
79
+ lastUsed: c.lastUsed,
80
+ }),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Fetch the clients list from the daemon API.
86
+ * Use this when running remotely (CLI in remote mode).
87
+ */
88
+ export async function getClientsList(): Promise<ClientEntry[]> {
89
+ const config = await loadConfig();
90
+ const result = await callDaemon("/clients");
91
+ const clients = (
92
+ result.output as
93
+ | {
94
+ clients?: Array<{
95
+ id: string;
96
+ name: string;
97
+ apiKey: string;
98
+ createdAt: number;
99
+ lastUsed?: number | null;
100
+ }>;
101
+ }
102
+ | undefined
103
+ )?.clients;
104
+
105
+ if (!clients) {
106
+ return [];
107
+ }
108
+
109
+ const suffix = config.daemonUrl ? getNpubSuffix(config) : null;
110
+
111
+ return clients
112
+ .filter((c) => !suffix || c.id.endsWith(`-${suffix}`))
113
+ .map((c) => ({
114
+ clientId: suffix ? removeSuffixFromId(c.id, suffix) : c.id,
115
+ name: c.name,
116
+ apiKey: c.apiKey,
117
+ createdAt: c.createdAt,
118
+ lastUsed: c.lastUsed,
119
+ }));
120
+ }
121
+
122
+ export async function addDaemonClient(
123
+ name: string,
124
+ clientId?: string,
125
+ ): Promise<{ message?: string; client: DaemonClient; created: boolean }> {
126
+ const existingClients = await getClientsList();
127
+ const existing = clientId
128
+ ? existingClients.find((c) => c.clientId === clientId)
129
+ : existingClients.find((c) => c.name === name);
130
+
131
+ if (existing) {
132
+ const client: DaemonClient = {
133
+ id: existing.clientId,
134
+ name: existing.name,
135
+ apiKey: existing.apiKey,
136
+ createdAt: existing.createdAt,
137
+ lastUsed: existing.lastUsed,
138
+ };
139
+ return { client, created: false };
140
+ }
141
+
142
+ // Derive id from name by replacing spaces with hyphens
143
+ const derivedId = name.replace(/\s+/g, "-").toLowerCase();
144
+
145
+ const result = await callDaemon("/clients/add", {
146
+ method: "POST",
147
+ body: { name, id: derivedId },
148
+ });
149
+
150
+
151
+ const output = result.output as
152
+ | { message?: string; client?: DaemonClient }
153
+ | undefined;
154
+
155
+ if (!output?.client?.apiKey) {
156
+ throw new Error(`Daemon did not return an API key for ${name}.`);
157
+ }
158
+
159
+ return { message: output.message, client: output.client, created: true };
160
+ }
161
+
162
+ export async function listClientsAction(): Promise<void> {
163
+ await ensureDaemonRunning();
164
+
165
+ const entries = await getClientsList();
166
+
167
+ const clients = entries.map((c) => ({
168
+ id: c.clientId,
169
+ name: c.name,
170
+ apiKey: c.apiKey,
171
+ createdAt: c.createdAt,
172
+ lastUsed: c.lastUsed,
173
+ }));
174
+
175
+ if (clients.length === 0) {
176
+ console.log("No clients found.");
177
+ return;
178
+ }
179
+
180
+ console.log(`Clients (${clients.length} total):\n`);
181
+ for (const client of clients) {
182
+ const createdAt = new Date(client.createdAt).toISOString();
183
+ const lastUsed = client.lastUsed
184
+ ? new Date(client.lastUsed).toISOString()
185
+ : "never";
186
+ console.log(` ${client.id}`);
187
+ console.log(` Name: ${client.name}`);
188
+ console.log(` API Key: ${client.apiKey}`);
189
+ console.log(` Created: ${createdAt}`);
190
+ console.log("");
191
+ }
192
+ }
193
+
194
+ export async function deleteClientAction(id: string): Promise<void> {
195
+ await ensureDaemonRunning();
196
+
197
+ const config = await loadConfig();
198
+ const suffix = getNpubSuffix(config);
199
+ const resolvedId = suffix ? addSuffixToId(id, suffix) : id;
200
+
201
+ const result = await callDaemon("/clients/delete", {
202
+ method: "POST",
203
+ body: { id: resolvedId },
204
+ });
205
+
206
+ if (result.error) {
207
+ console.log(result.error);
208
+ process.exit(1);
209
+ }
210
+
211
+ const output = result.output as
212
+ | {
213
+ message: string;
214
+ id: string;
215
+ }
216
+ | undefined;
217
+
218
+ if (output) {
219
+ console.log(output.message);
220
+ }
221
+ }
222
+
223
+ export interface AddClientOptions {
224
+ name?: string;
225
+ opencode?: boolean;
226
+ openclaw?: boolean;
227
+ piAgent?: boolean;
228
+ claudeCode?: boolean;
229
+ }
230
+
231
+ export async function addClientAction(options: AddClientOptions): Promise<void> {
232
+ await ensureDaemonRunning();
233
+ const config = await loadConfig();
234
+
235
+ const integrationKeys: string[] = [];
236
+ if (options.opencode) integrationKeys.push("opencode");
237
+ if (options.openclaw) integrationKeys.push("openclaw");
238
+ if (options.piAgent) integrationKeys.push("pi-agent");
239
+ if (options.claudeCode) integrationKeys.push("claude-code");
240
+
241
+ if (integrationKeys.length > 0) {
242
+ for (const key of integrationKeys) {
243
+ const integrationFn = CLIENT_INTEGRATIONS[key];
244
+ const integrationConfig = CLIENT_CONFIGS[key];
245
+ if (!integrationFn || !integrationConfig) continue;
246
+
247
+ try {
248
+ const { client, created } = await addDaemonClient(
249
+ integrationConfig.name,
250
+ integrationConfig.clientId,
251
+ );
252
+ if (created) {
253
+ logger.log(`Created new API key for ${integrationConfig.name}`);
254
+ } else {
255
+ logger.log(`Using existing API key for ${integrationConfig.name}`);
256
+ }
257
+ await integrationFn(config, client.apiKey, integrationConfig);
258
+
259
+ console.log(`\n ${integrationConfig.name}:`);
260
+ console.log(` Client ID: ${client.id}`);
261
+ console.log(` API Key: ${client.apiKey}`);
262
+ } catch (error) {
263
+ logger.error(
264
+ `Failed to set up ${integrationConfig.name} integration:`,
265
+ error,
266
+ );
267
+ continue;
268
+ }
269
+ }
270
+
271
+ console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
272
+ return;
273
+ }
274
+
275
+ if (!options.name) {
276
+ console.error(
277
+ "error: required option '-n, --name <name>' not specified",
278
+ );
279
+ process.exit(1);
280
+ }
281
+
282
+ try {
283
+ const { message, client, created } = await addDaemonClient(options.name);
284
+
285
+ if (!created) {
286
+ console.log(`Client '${options.name}' already exists.`);
287
+ console.log(`\n ID: ${client.id}`);
288
+ console.log(` Name: ${client.name}`);
289
+ console.log(` API Key: ${client.apiKey}`);
290
+ return;
291
+ }
292
+
293
+ if (message) {
294
+ console.log(message);
295
+ }
296
+ console.log(`\n ID: ${client.id}`);
297
+ console.log(` Name: ${client.name}`);
298
+ console.log(` API Key: ${client.apiKey}`);
299
+ console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
300
+ } catch (error) {
301
+ console.log((error as Error).message);
302
+ process.exit(1);
303
+ }
304
+ }
@@ -12,6 +12,8 @@ export interface RoutstrdConfig {
12
12
  provider: string | null;
13
13
  cocodPath: string | null;
14
14
  mode?: "xcashu" | "apikeys";
15
+ daemonUrl?: string;
16
+ nsec?: string;
15
17
  }
16
18
 
17
19
  export const DEFAULT_CONFIG: RoutstrdConfig = {
@@ -1,10 +1,16 @@
1
1
  import { existsSync } from "fs";
2
+ import { startDaemon } from "../start-daemon";
2
3
  import {
3
4
  CONFIG_FILE,
4
5
  DEFAULT_CONFIG,
5
- LOGS_DIR,
6
6
  type RoutstrdConfig,
7
7
  } from "./config";
8
+ import {
9
+ createNIP98Authorization,
10
+ parseSecretKey,
11
+ npubFromSecretKey,
12
+ type HttpMethod,
13
+ } from "./nip98";
8
14
 
9
15
  export interface CommandResponse {
10
16
  output?: unknown;
@@ -23,17 +29,44 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
23
29
  return DEFAULT_CONFIG;
24
30
  }
25
31
 
32
+ export function getDaemonBaseUrl(config: RoutstrdConfig): string {
33
+ return (
34
+ config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}`
35
+ );
36
+ }
37
+
26
38
  export async function callDaemon(
27
39
  path: string,
28
- options: { method?: "GET" | "POST"; body?: object } = {},
40
+ options: { method?: "GET" | "POST" | "DELETE"; body?: object } = {},
29
41
  ): Promise<CommandResponse> {
30
42
  const { method = "GET", body } = options;
31
43
  const config = await loadConfig();
44
+ const baseUrl = getDaemonBaseUrl(config);
45
+ const url = `${baseUrl}${path}`;
46
+
47
+ const bodyString = body ? JSON.stringify(body) : undefined;
48
+ const bodyBytes = bodyString
49
+ ? new TextEncoder().encode(bodyString)
50
+ : undefined;
51
+
52
+ let authorization: string | undefined;
53
+ if (config.daemonUrl && config.nsec) {
54
+ const secretKey = parseSecretKey(config.nsec);
55
+ authorization = await createNIP98Authorization(
56
+ secretKey,
57
+ url,
58
+ method as HttpMethod,
59
+ bodyBytes,
60
+ );
61
+ }
32
62
 
33
- const response = await fetch(`http://localhost:${config.port}${path}`, {
63
+ const response = await fetch(url, {
34
64
  method,
35
- headers: body ? { "Content-Type": "application/json" } : {},
36
- body: body ? JSON.stringify(body) : undefined,
65
+ headers: {
66
+ ...(authorization ? { Authorization: authorization } : {}),
67
+ ...(bodyString ? { "Content-Type": "application/json" } : {}),
68
+ },
69
+ body: bodyString,
37
70
  });
38
71
 
39
72
  if (!response.ok) {
@@ -47,36 +80,46 @@ export async function callDaemon(
47
80
  export async function isDaemonRunning(): Promise<boolean> {
48
81
  try {
49
82
  const config = await loadConfig();
50
- const response = await fetch(`http://localhost:${config.port}/health`);
83
+ const baseUrl = getDaemonBaseUrl(config);
84
+ const url = `${baseUrl}/health`;
85
+
86
+ let authorization: string | undefined;
87
+ if (config.daemonUrl && config.nsec) {
88
+ const secretKey = parseSecretKey(config.nsec);
89
+ authorization = await createNIP98Authorization(secretKey, url, "GET");
90
+ }
91
+
92
+ const response = await fetch(url, {
93
+ headers: authorization ? { Authorization: authorization } : {},
94
+ });
51
95
  return response.ok;
52
96
  } catch {
53
97
  return false;
54
98
  }
55
99
  }
56
100
 
57
- export async function startDaemonProcess(): Promise<void> {
58
- // Ensure logs directory exists (logger handles date-based files)
59
- if (!existsSync(LOGS_DIR)) {
60
- await Bun.$`mkdir -p ${LOGS_DIR}`;
101
+ export function getUserNpub(config: RoutstrdConfig): string | null {
102
+ if (!config.nsec) return null;
103
+ try {
104
+ const secretKey = parseSecretKey(config.nsec);
105
+ return npubFromSecretKey(secretKey);
106
+ } catch {
107
+ return null;
61
108
  }
109
+ }
62
110
 
63
- const proc = Bun.spawn(["bun", "run", `${import.meta.dir}/../daemon/index.ts`], {
64
- stdout: "inherit",
65
- stderr: "inherit",
66
- stdin: "ignore",
67
- detached: true,
68
- });
69
-
70
- proc.unref();
71
-
72
- for (let i = 0; i < 50; i++) {
73
- await new Promise((resolve) => setTimeout(resolve, 100));
74
- if (await isDaemonRunning()) {
75
- return;
76
- }
77
- }
111
+ export function getNpubSuffix(config: RoutstrdConfig): string | null {
112
+ const npub = getUserNpub(config);
113
+ if (!npub) return null;
114
+ return npub.slice(-7);
115
+ }
78
116
 
79
- throw new Error("Daemon failed to start within 5 seconds");
117
+ export async function startDaemonProcess(): Promise<void> {
118
+ const config = await loadConfig();
119
+ await startDaemon({
120
+ port: String(config.port || 8008),
121
+ provider: config.provider || undefined,
122
+ });
80
123
  }
81
124
 
82
125
  export async function ensureDaemonRunning(): Promise<void> {
@@ -84,6 +127,11 @@ export async function ensureDaemonRunning(): Promise<void> {
84
127
  return;
85
128
  }
86
129
 
130
+ const config = await loadConfig();
131
+ if (config.daemonUrl) {
132
+ throw new Error(`Daemon is not reachable at ${config.daemonUrl}`);
133
+ }
134
+
87
135
  console.log("Starting daemon...");
88
136
  await startDaemonProcess();
89
137
  }
@@ -117,11 +165,14 @@ export async function handleDaemonCommand(
117
165
  return result;
118
166
  } catch (error) {
119
167
  const message = (error as Error).message;
120
- if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
168
+ if (
169
+ message?.includes("fetch failed") ||
170
+ message?.includes("Connection refused")
171
+ ) {
121
172
  console.error("Daemon is not running and failed to auto-start");
122
173
  process.exit(1);
123
174
  }
124
175
  console.error(message);
125
176
  process.exit(1);
126
177
  }
127
- }
178
+ }
@@ -0,0 +1,102 @@
1
+ import { finalizeEvent, getPublicKey, nip19, type EventTemplate } from "nostr-tools";
2
+
3
+ const NIP98_KIND = 27235;
4
+
5
+ export type HttpMethod = "GET" | "POST" | "DELETE";
6
+
7
+ export function hexToBytes(hex: string): Uint8Array {
8
+ const normalized = hex.trim().toLowerCase();
9
+ if (!/^[a-f0-9]{64}$/.test(normalized)) {
10
+ throw new Error("Expected a 64-char hex private key or an nsec private key.");
11
+ }
12
+
13
+ const bytes = new Uint8Array(32);
14
+ for (let i = 0; i < bytes.length; i++) {
15
+ bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
16
+ }
17
+ return bytes;
18
+ }
19
+
20
+ export function parseSecretKey(value: string): Uint8Array {
21
+ const trimmed = value.trim();
22
+ if (!trimmed) {
23
+ throw new Error("Missing Nostr private key.");
24
+ }
25
+
26
+ if (trimmed.toLowerCase().startsWith("nsec1")) {
27
+ const decoded = nip19.decode(trimmed);
28
+ if (decoded.type !== "nsec" || !(decoded.data instanceof Uint8Array)) {
29
+ throw new Error("Invalid nsec private key.");
30
+ }
31
+ return decoded.data;
32
+ }
33
+
34
+ return hexToBytes(trimmed);
35
+ }
36
+
37
+ async function sha256Hex(data: Uint8Array): Promise<string> {
38
+ const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
39
+ return [...new Uint8Array(digest)]
40
+ .map((byte) => byte.toString(16).padStart(2, "0"))
41
+ .join("");
42
+ }
43
+
44
+ function base64EncodeUtf8(value: string): string {
45
+ return btoa(String.fromCharCode(...new TextEncoder().encode(value)));
46
+ }
47
+
48
+ export function normalizeNostrPubkey(value: string): string | null {
49
+ const trimmed = value.trim();
50
+ if (!trimmed) return null;
51
+
52
+ if (/^[a-f0-9]{64}$/i.test(trimmed)) {
53
+ return trimmed.toLowerCase();
54
+ }
55
+
56
+ if (trimmed.toLowerCase().startsWith("npub1")) {
57
+ try {
58
+ const decoded = nip19.decode(trimmed);
59
+ if (decoded.type === "npub" && typeof decoded.data === "string") {
60
+ return decoded.data.toLowerCase();
61
+ }
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ export function npubFromPubkey(pubkey: string): string {
71
+ return nip19.npubEncode(pubkey.toLowerCase());
72
+ }
73
+
74
+ export function npubFromSecretKey(secretKey: Uint8Array): string {
75
+ return npubFromPubkey(getPublicKey(secretKey));
76
+ }
77
+
78
+ export async function createNIP98Authorization(
79
+ secretKey: Uint8Array,
80
+ url: string,
81
+ method: HttpMethod,
82
+ body?: Uint8Array,
83
+ ): Promise<string> {
84
+ const tags = [
85
+ ["u", url],
86
+ ["method", method.toUpperCase()],
87
+ ];
88
+
89
+ if (body && body.byteLength > 0) {
90
+ tags.push(["payload", await sha256Hex(body)]);
91
+ }
92
+
93
+ const template: EventTemplate = {
94
+ kind: NIP98_KIND,
95
+ created_at: Math.round(Date.now() / 1000),
96
+ content: "",
97
+ tags,
98
+ };
99
+
100
+ const signed = finalizeEvent(template, secretKey);
101
+ return `Nostr ${base64EncodeUtf8(JSON.stringify(signed))}`;
102
+ }
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "crypto";
2
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
3
+ import { dirname } from "path";
4
+
5
+ export interface CrossProcessLockOptions {
6
+ /** How long to wait while another process holds the lock. */
7
+ acquireTimeoutMs?: number;
8
+ /** How often to retry acquiring the lock. */
9
+ retryIntervalMs?: number;
10
+ /** Treat locks older than this as stale even if their PID cannot be checked. */
11
+ staleAfterMs?: number;
12
+ /** Optional logger used when removing stale locks. */
13
+ log?: (message: string) => void;
14
+ }
15
+
16
+ interface LockOwner {
17
+ pid: number;
18
+ createdAt: number;
19
+ token?: string;
20
+ }
21
+
22
+ function delay(ms: number): Promise<void> {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+
26
+ function isProcessRunning(pid: number): boolean {
27
+ if (!Number.isFinite(pid) || pid <= 0) {
28
+ return false;
29
+ }
30
+
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch (error) {
35
+ const code = (error as NodeJS.ErrnoException).code;
36
+ return code === "EPERM";
37
+ }
38
+ }
39
+
40
+ async function readLockOwner(lockDir: string): Promise<LockOwner | null> {
41
+ try {
42
+ const raw = await readFile(`${lockDir}/owner.json`, "utf8");
43
+ const parsed = JSON.parse(raw) as Partial<LockOwner>;
44
+ if (
45
+ typeof parsed.pid === "number" &&
46
+ typeof parsed.createdAt === "number"
47
+ ) {
48
+ return {
49
+ pid: parsed.pid,
50
+ createdAt: parsed.createdAt,
51
+ token: typeof parsed.token === "string" ? parsed.token : undefined,
52
+ };
53
+ }
54
+ } catch {
55
+ // The lock may have been created but not fully written yet.
56
+ }
57
+ return null;
58
+ }
59
+
60
+ async function isLockStale(
61
+ lockDir: string,
62
+ staleAfterMs: number,
63
+ ): Promise<boolean> {
64
+ const owner = await readLockOwner(lockDir);
65
+ if (owner) {
66
+ return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
67
+ }
68
+
69
+ try {
70
+ const info = await stat(lockDir);
71
+ return Date.now() - info.mtimeMs > staleAfterMs;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ export async function acquireCrossProcessLock(
78
+ lockDir: string,
79
+ options: CrossProcessLockOptions = {},
80
+ ): Promise<() => Promise<void>> {
81
+ const acquireTimeoutMs = options.acquireTimeoutMs ?? 120_000;
82
+ const retryIntervalMs = options.retryIntervalMs ?? 100;
83
+ const staleAfterMs = options.staleAfterMs ?? 120_000;
84
+ const deadline = Date.now() + acquireTimeoutMs;
85
+
86
+ await mkdir(dirname(lockDir), { recursive: true });
87
+
88
+ while (true) {
89
+ try {
90
+ await mkdir(lockDir);
91
+ const token = randomUUID();
92
+ const owner: LockOwner = { pid: process.pid, createdAt: Date.now(), token };
93
+ await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
94
+ let released = false;
95
+ return async () => {
96
+ if (released) return;
97
+ released = true;
98
+
99
+ const currentOwner = await readLockOwner(lockDir);
100
+ if (currentOwner?.token === token) {
101
+ await rm(lockDir, { recursive: true, force: true });
102
+ }
103
+ };
104
+ } catch (error) {
105
+ const code = (error as NodeJS.ErrnoException).code;
106
+ if (code !== "EEXIST") {
107
+ throw error;
108
+ }
109
+
110
+ if (await isLockStale(lockDir, staleAfterMs)) {
111
+ options.log?.(`Removing stale lock at ${lockDir}`);
112
+ await rm(lockDir, { recursive: true, force: true });
113
+ continue;
114
+ }
115
+
116
+ if (Date.now() >= deadline) {
117
+ throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
118
+ }
119
+
120
+ await delay(retryIntervalMs);
121
+ }
122
+ }
123
+ }
124
+
125
+ export async function withCrossProcessLock<T>(
126
+ lockDir: string,
127
+ fn: () => Promise<T>,
128
+ options: CrossProcessLockOptions = {},
129
+ ): Promise<T> {
130
+ const release = await acquireCrossProcessLock(lockDir, options);
131
+ try {
132
+ return await fn();
133
+ } finally {
134
+ await release();
135
+ }
136
+ }