@cortexkit/common-auth 0.1.0

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/dist/fs/atomic-write.d.ts +5 -0
  4. package/dist/fs/atomic-write.js +22 -0
  5. package/dist/fs/index.d.ts +5 -0
  6. package/dist/fs/index.js +3 -0
  7. package/dist/fs/lock-constants.d.ts +14 -0
  8. package/dist/fs/lock-constants.js +14 -0
  9. package/dist/fs/refresh-file-lock.d.ts +18 -0
  10. package/dist/fs/refresh-file-lock.js +392 -0
  11. package/dist/fs/with-lock.d.ts +32 -0
  12. package/dist/fs/with-lock.js +48 -0
  13. package/dist/logger/capture-sink.d.ts +14 -0
  14. package/dist/logger/capture-sink.js +13 -0
  15. package/dist/logger/engine.d.ts +44 -0
  16. package/dist/logger/engine.js +199 -0
  17. package/dist/logger/index.d.ts +6 -0
  18. package/dist/logger/index.js +3 -0
  19. package/dist/logger/redact.d.ts +11 -0
  20. package/dist/logger/redact.js +45 -0
  21. package/dist/rpc/index.d.ts +10 -0
  22. package/dist/rpc/index.js +5 -0
  23. package/dist/rpc/notifications.d.ts +29 -0
  24. package/dist/rpc/notifications.js +57 -0
  25. package/dist/rpc/port-file.d.ts +20 -0
  26. package/dist/rpc/port-file.js +164 -0
  27. package/dist/rpc/rpc-client.d.ts +8 -0
  28. package/dist/rpc/rpc-client.js +51 -0
  29. package/dist/rpc/rpc-server.d.ts +19 -0
  30. package/dist/rpc/rpc-server.js +135 -0
  31. package/dist/rpc/server-registry.d.ts +11 -0
  32. package/dist/rpc/server-registry.js +47 -0
  33. package/dist/sidebar-file/index.d.ts +2 -0
  34. package/dist/sidebar-file/index.js +1 -0
  35. package/dist/sidebar-file/sidebar-file.d.ts +23 -0
  36. package/dist/sidebar-file/sidebar-file.js +84 -0
  37. package/dist/tui/index.d.ts +8 -0
  38. package/dist/tui/index.js +21 -0
  39. package/dist/tui-build/build-tui.d.ts +23 -0
  40. package/dist/tui-build/build-tui.js +190 -0
  41. package/dist/tui-build/index.d.ts +3 -0
  42. package/dist/tui-build/index.js +2 -0
  43. package/dist/tui-build/publish-list.d.ts +2 -0
  44. package/dist/tui-build/publish-list.js +20 -0
  45. package/dist/tui-build/walker.d.ts +8 -0
  46. package/dist/tui-build/walker.js +171 -0
  47. package/dist/tui-prefs/index.d.ts +2 -0
  48. package/dist/tui-prefs/index.js +2 -0
  49. package/dist/tui-prefs/tui-preferences.d.ts +25 -0
  50. package/dist/tui-prefs/tui-preferences.js +77 -0
  51. package/dist/tui-prefs/watcher.d.ts +6 -0
  52. package/dist/tui-prefs/watcher.js +74 -0
  53. package/package.json +89 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ex Machina
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # common-auth
2
+
3
+ Shared libraries for the CortexKit auth plugins for OpenCode and Pi: openai-auth, anthropic-auth and antigravity-auth. Nothing is published yet.
4
+
5
+ `research/` holds the spike reports that inform the design:
6
+
7
+ - [OpenCode 2 transport spike](research/opencode2-transport/REPORT.md): can a plugin own the transport on OpenCode 2?
8
+ - [OpenCode 2 native-driver spike](research/opencode2-thin/REPORT.md): multi-account support through hooks on OpenCode 2's built-in OpenAI driver.
9
+
10
+ Licensed under MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,5 @@
1
+ export interface AtomicWriteOptions {
2
+ serialize?: (value: unknown) => string;
3
+ beforeRename?: () => Promise<void>;
4
+ }
5
+ export declare function writeJsonAtomic(path: string, value: unknown, options?: AtomicWriteOptions): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ export async function writeJsonAtomic(path, value, options = {}) {
5
+ await mkdir(dirname(path), { recursive: true });
6
+ const tempPath = `${path}.${randomUUID()}.tmp`;
7
+ try {
8
+ await writeFile(tempPath, options.serialize
9
+ ? options.serialize(value)
10
+ : `${JSON.stringify(value, null, 2)}\n`, {
11
+ encoding: 'utf8',
12
+ mode: 0o600,
13
+ });
14
+ await options.beforeRename?.();
15
+ await rename(tempPath, path);
16
+ }
17
+ catch (error) {
18
+ // A failed write can leave partial staging bytes just like a failed rename.
19
+ await rm(tempPath, { force: true }).catch(() => { });
20
+ throw error;
21
+ }
22
+ }
@@ -0,0 +1,5 @@
1
+ export type { AtomicWriteOptions } from './atomic-write.js';
2
+ export { writeJsonAtomic } from './atomic-write.js';
3
+ export { WRITER_LOCK_CONSTANTS } from './lock-constants.js';
4
+ export type { LockOptions } from './with-lock.js';
5
+ export { LockContentionError, LockOwnershipError, lockPathFor, withLock, } from './with-lock.js';
@@ -0,0 +1,3 @@
1
+ export { writeJsonAtomic } from './atomic-write.js';
2
+ export { WRITER_LOCK_CONSTANTS } from './lock-constants.js';
3
+ export { LockContentionError, LockOwnershipError, lockPathFor, withLock, } from './with-lock.js';
@@ -0,0 +1,14 @@
1
+ export declare const WRITER_LOCK_CONSTANTS: Readonly<{
2
+ sidebar: Readonly<{
3
+ name: "sidebar-write";
4
+ ttlMs: 10000;
5
+ timeoutMs: 15000;
6
+ renew: true;
7
+ }>;
8
+ preferences: Readonly<{
9
+ name: "preferences";
10
+ ttlMs: 10000;
11
+ timeoutMs: 2000;
12
+ renew: true;
13
+ }>;
14
+ }>;
@@ -0,0 +1,14 @@
1
+ export const WRITER_LOCK_CONSTANTS = Object.freeze({
2
+ sidebar: Object.freeze({
3
+ name: 'sidebar-write',
4
+ ttlMs: 10_000,
5
+ timeoutMs: 15_000,
6
+ renew: true,
7
+ }),
8
+ preferences: Object.freeze({
9
+ name: 'preferences',
10
+ ttlMs: 10_000,
11
+ timeoutMs: 2_000,
12
+ renew: true,
13
+ }),
14
+ });
@@ -0,0 +1,18 @@
1
+ export declare function isLostMarkerRaceError(error: unknown): boolean;
2
+ export declare function acquireRefreshFileLock(options: {
3
+ name: string;
4
+ ttlMs: number;
5
+ /**
6
+ * File the lock is named after. Required: the lock and the write it guards
7
+ * must target the same file, and a default resolved in here could only ever
8
+ * be one host's path.
9
+ */
10
+ path: string;
11
+ now?: () => number;
12
+ renew?: boolean;
13
+ renewIntervalMs?: number;
14
+ onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-confirmed' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
15
+ }): Promise<{
16
+ release: () => Promise<void>;
17
+ assertOwned: () => Promise<void>;
18
+ } | null>;
@@ -0,0 +1,392 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { LockOwnershipError, lockPathFor } from './with-lock.js';
5
+ const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
6
+ const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
7
+ // A concurrent contender renaming the freshly-created eviction-marker directory
8
+ // away surfaces the vanished parent differently per platform: ENOENT on Linux,
9
+ // EINVAL or ENOTDIR on macOS/APFS. All three mean the marker is no longer ours
10
+ // to hold — a lost race the caller should retry, not a fatal lock error.
11
+ export function isLostMarkerRaceError(error) {
12
+ const code = error?.code;
13
+ return code === 'ENOENT' || code === 'EINVAL' || code === 'ENOTDIR';
14
+ }
15
+ export async function acquireRefreshFileLock(options) {
16
+ const lockPath = lockPathFor(options.path, options.name);
17
+ const legacyOwnerPath = join(lockPath, 'owner.json');
18
+ const ownerId = randomUUID();
19
+ const now = options.now ?? Date.now;
20
+ let renewTimer = null;
21
+ let released = false;
22
+ let renewalInFlight = null;
23
+ // Only the owner of this exclusively-created marker may remove or renew a
24
+ // lock. A contender recovering a stale marker can accidentally rename a newer
25
+ // marker instead; checking its owner file before each mutation prevents the
26
+ // displaced owner from continuing to act on the lock.
27
+ const evictPath = `${lockPath}.evicting`;
28
+ const evictOwnerPath = join(evictPath, 'owner.json');
29
+ const evictOwnerId = randomUUID();
30
+ const EVICT_TTL = 5_000;
31
+ const MAX_STEAL_ATTEMPTS = 8;
32
+ async function readOwner() {
33
+ try {
34
+ return JSON.parse(await readFile(lockPath, 'utf8'));
35
+ }
36
+ catch (error) {
37
+ const code = error.code;
38
+ if (code !== 'EISDIR')
39
+ throw error;
40
+ return JSON.parse(await readFile(legacyOwnerPath, 'utf8'));
41
+ }
42
+ }
43
+ async function writeOwner() {
44
+ // Readers must see a complete lease, even while renewal is writing.
45
+ const tempPath = `${lockPath}.${randomUUID()}.tmp`;
46
+ try {
47
+ await writeFile(tempPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
48
+ await rename(tempPath, lockPath);
49
+ }
50
+ finally {
51
+ await rm(tempPath, { force: true }).catch(() => { });
52
+ }
53
+ }
54
+ async function tryAcquire() {
55
+ try {
56
+ await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ const code = error.code;
61
+ if (code === 'EEXIST' || code === 'EISDIR')
62
+ return false;
63
+ if (code === 'ENOENT') {
64
+ // Reboots can clear runtime temp directories before the next lock acquisition.
65
+ await mkdir(dirname(lockPath), { recursive: true });
66
+ try {
67
+ await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
68
+ return true;
69
+ }
70
+ catch (retryError) {
71
+ const retryCode = retryError.code;
72
+ if (retryCode === 'EEXIST' || retryCode === 'EISDIR')
73
+ return false;
74
+ throw retryError;
75
+ }
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+ async function backoff() {
81
+ await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4)));
82
+ }
83
+ async function lockIsLive() {
84
+ try {
85
+ const currentOwner = await readOwner();
86
+ return Number(currentOwner?.expiresAt) > now();
87
+ }
88
+ catch {
89
+ try {
90
+ const current = await stat(lockPath);
91
+ return current.mtimeMs + options.ttlMs > now();
92
+ }
93
+ catch {
94
+ // Lock doesn't exist — safe to acquire.
95
+ return false;
96
+ }
97
+ }
98
+ }
99
+ // Fail-closed: any read error means we do NOT own the marker.
100
+ async function ownsEvictionMarker() {
101
+ try {
102
+ const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'));
103
+ return owner?.ownerId === evictOwnerId;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ async function releaseEvictionMarker() {
110
+ if (await ownsEvictionMarker()) {
111
+ await rm(evictPath, { recursive: true, force: true }).catch(() => { });
112
+ }
113
+ }
114
+ async function tryAcquireEvictionMarker() {
115
+ await mkdir(evictPath);
116
+ try {
117
+ await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
118
+ }
119
+ catch (error) {
120
+ // A competing contender can rename our just-created marker directory
121
+ // away between the mkdir above and this write (the stale-marker steal
122
+ // path below does exactly that). That is a lost race, not a failure, so
123
+ // report it as such and let the caller back off and retry rather than
124
+ // failing the whole lock acquisition.
125
+ if (isLostMarkerRaceError(error))
126
+ return false;
127
+ await releaseEvictionMarker();
128
+ throw error;
129
+ }
130
+ if (options.onStep)
131
+ await options.onStep('eviction-marker-acquired');
132
+ return true;
133
+ }
134
+ async function recoverStaleEvictionMarker() {
135
+ let evictStat;
136
+ try {
137
+ evictStat = await stat(evictPath);
138
+ }
139
+ catch (error) {
140
+ if (error.code === 'ENOENT')
141
+ return 'missing';
142
+ throw error;
143
+ }
144
+ if (evictStat.mtimeMs + EVICT_TTL > now())
145
+ return 'fresh';
146
+ if (options.onStep)
147
+ await options.onStep('stale-marker-stat');
148
+ const claimedPath = `${evictPath}.${randomUUID()}`;
149
+ try {
150
+ await rename(evictPath, claimedPath);
151
+ }
152
+ catch (error) {
153
+ if (error.code === 'ENOENT')
154
+ return 'missing';
155
+ throw error;
156
+ }
157
+ if (options.onStep)
158
+ await options.onStep('stale-marker-claimed');
159
+ await rm(claimedPath, { recursive: true, force: true }).catch(() => { });
160
+ return 'recovered';
161
+ }
162
+ async function withEvictionMarker(action) {
163
+ try {
164
+ if (!(await tryAcquireEvictionMarker()))
165
+ return false;
166
+ }
167
+ catch (error) {
168
+ if (error.code === 'EEXIST')
169
+ return false;
170
+ throw error;
171
+ }
172
+ try {
173
+ await action();
174
+ }
175
+ finally {
176
+ await releaseEvictionMarker();
177
+ }
178
+ return true;
179
+ }
180
+ // Marker loss after a write may mean our record replaced a successor's.
181
+ // Delete only a record still owned by us; a concurrent successor write can
182
+ // then yield zero winners, never two.
183
+ async function relinquishLockAfterMarkerLoss() {
184
+ for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
185
+ if (options.onStep)
186
+ await options.onStep('relinquish-read');
187
+ let owner;
188
+ try {
189
+ owner = await readOwner();
190
+ }
191
+ catch {
192
+ return;
193
+ }
194
+ if (owner?.ownerId !== ownerId)
195
+ return;
196
+ try {
197
+ await rm(lockPath, { recursive: true, force: true });
198
+ return;
199
+ }
200
+ catch {
201
+ await backoff();
202
+ }
203
+ }
204
+ }
205
+ function scheduleRenewal() {
206
+ if (!options.renew || released)
207
+ return;
208
+ const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
209
+ renewTimer = setRefreshLockRenewalTimeout(() => {
210
+ const renewal = (async () => {
211
+ let shouldReschedule = !released;
212
+ try {
213
+ const markerAcquired = await withEvictionMarker(async () => {
214
+ const owner = await readOwner();
215
+ const currentNow = now();
216
+ if (released || owner?.ownerId !== ownerId) {
217
+ shouldReschedule = false;
218
+ return;
219
+ }
220
+ // An expired lease is no longer ours to extend; a contender may
221
+ // already be eligible to acquire it.
222
+ if (Number(owner?.expiresAt) <= currentNow) {
223
+ shouldReschedule = false;
224
+ return;
225
+ }
226
+ if (options.onStep)
227
+ await options.onStep('renewal-owner-confirmed');
228
+ if (released) {
229
+ shouldReschedule = false;
230
+ return;
231
+ }
232
+ if (!(await ownsEvictionMarker()))
233
+ return;
234
+ if (options.onStep)
235
+ await options.onStep('renewal-write-fenced');
236
+ if (released) {
237
+ shouldReschedule = false;
238
+ return;
239
+ }
240
+ if (!(await ownsEvictionMarker()))
241
+ return;
242
+ if (options.onStep)
243
+ await options.onStep('renewal-write-ready');
244
+ await writeOwner();
245
+ if (!(await ownsEvictionMarker())) {
246
+ // If marker ownership cannot be read, stop claiming the lease
247
+ // and remove only a record that still carries our owner id.
248
+ shouldReschedule = false;
249
+ await relinquishLockAfterMarkerLoss();
250
+ return;
251
+ }
252
+ });
253
+ if (!markerAcquired && options.onStep) {
254
+ await options.onStep('renewal-marker-unavailable');
255
+ }
256
+ }
257
+ catch {
258
+ // Transient marker and filesystem failures retry on the next interval.
259
+ }
260
+ finally {
261
+ if (options.onStep) {
262
+ try {
263
+ await options.onStep('renewal-finished');
264
+ }
265
+ catch {
266
+ // Errors from the onStep observer must not reject the renewal.
267
+ }
268
+ }
269
+ if (shouldReschedule && !released)
270
+ scheduleRenewal();
271
+ }
272
+ })();
273
+ renewalInFlight = renewal;
274
+ void renewal.finally(() => {
275
+ if (renewalInFlight === renewal)
276
+ renewalInFlight = null;
277
+ });
278
+ }, intervalMs);
279
+ if ('unref' in renewTimer)
280
+ renewTimer.unref();
281
+ }
282
+ let acquired = await tryAcquire();
283
+ if (!acquired) {
284
+ for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
285
+ acquired = await tryAcquire();
286
+ if (acquired)
287
+ break;
288
+ if (await lockIsLive())
289
+ return null;
290
+ try {
291
+ if (!(await tryAcquireEvictionMarker())) {
292
+ await backoff();
293
+ continue;
294
+ }
295
+ }
296
+ catch (evictError) {
297
+ const code = evictError.code;
298
+ if (code !== 'EEXIST')
299
+ throw evictError;
300
+ const recovered = await recoverStaleEvictionMarker();
301
+ if (recovered === 'fresh')
302
+ return null;
303
+ await backoff();
304
+ continue;
305
+ }
306
+ try {
307
+ if (await lockIsLive())
308
+ return null;
309
+ // Verify marker ownership before removing a lock found not live.
310
+ if (!(await ownsEvictionMarker()))
311
+ return null;
312
+ if (options.onStep)
313
+ await options.onStep('stale-lock-confirmed');
314
+ // The onStep callback can pause while another contender renames
315
+ // our marker, so verify ownership again after the callback.
316
+ if (!(await ownsEvictionMarker()))
317
+ return null;
318
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
319
+ // Fence check 3: re-verify ownership after removing the stale lock.
320
+ if (!(await ownsEvictionMarker()))
321
+ return null;
322
+ acquired = await tryAcquire();
323
+ if (!acquired)
324
+ return null;
325
+ // Fence check 4: re-verify ownership after acquiring the lock. If the
326
+ // marker was stolen between tryAcquire and this check, release the
327
+ // just-acquired lock and return null (fail-closed).
328
+ if (!(await ownsEvictionMarker())) {
329
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
330
+ acquired = false;
331
+ return null;
332
+ }
333
+ break;
334
+ }
335
+ finally {
336
+ await releaseEvictionMarker();
337
+ }
338
+ }
339
+ }
340
+ if (!acquired)
341
+ return null;
342
+ scheduleRenewal();
343
+ return {
344
+ assertOwned: async () => {
345
+ try {
346
+ const owner = await readOwner();
347
+ if (!released &&
348
+ owner?.ownerId === ownerId &&
349
+ Number(owner?.expiresAt) > now())
350
+ return;
351
+ }
352
+ catch {
353
+ // Unreadable ownership is not evidence of a valid lease.
354
+ }
355
+ throw new LockOwnershipError({
356
+ target: options.path,
357
+ name: options.name,
358
+ });
359
+ },
360
+ release: async () => {
361
+ released = true;
362
+ if (renewTimer) {
363
+ clearRefreshLockRenewalTimeout(renewTimer);
364
+ renewTimer = null;
365
+ }
366
+ await renewalInFlight;
367
+ for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
368
+ try {
369
+ const markerAcquired = await withEvictionMarker(async () => {
370
+ const owner = await readOwner();
371
+ if (owner?.ownerId !== ownerId)
372
+ return;
373
+ if (options.onStep)
374
+ await options.onStep('release-owner-confirmed');
375
+ if (!(await ownsEvictionMarker()))
376
+ return;
377
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
378
+ });
379
+ if (markerAcquired)
380
+ return;
381
+ await recoverStaleEvictionMarker();
382
+ }
383
+ catch {
384
+ return;
385
+ }
386
+ await backoff();
387
+ }
388
+ // Do not delete by pathname without the marker: bounded retries leave the
389
+ // lease to expire rather than risking removal of a successor's lock.
390
+ },
391
+ };
392
+ }
@@ -0,0 +1,32 @@
1
+ export declare function lockPathFor(target: string, name: string): string;
2
+ export declare class LockContentionError extends Error {
3
+ readonly details: {
4
+ target: string;
5
+ name: string;
6
+ timeoutMs: number;
7
+ };
8
+ constructor(details: {
9
+ target: string;
10
+ name: string;
11
+ timeoutMs: number;
12
+ });
13
+ }
14
+ export declare class LockOwnershipError extends Error {
15
+ readonly details: {
16
+ target: string;
17
+ name: string;
18
+ };
19
+ constructor(details: {
20
+ target: string;
21
+ name: string;
22
+ });
23
+ }
24
+ export interface LockOptions {
25
+ name: string;
26
+ ttlMs: number;
27
+ timeoutMs: number;
28
+ renew?: boolean;
29
+ }
30
+ export declare function withLock<T>(target: string, options: LockOptions, fn: (lock: {
31
+ assertOwned(): Promise<void>;
32
+ }) => Promise<T>): Promise<T>;
@@ -0,0 +1,48 @@
1
+ import { acquireRefreshFileLock } from './refresh-file-lock.js';
2
+ export function lockPathFor(target, name) {
3
+ return `${target}.${name}.lock`;
4
+ }
5
+ export class LockContentionError extends Error {
6
+ details;
7
+ constructor(details) {
8
+ super(`Timed out acquiring ${details.name} lock for ${details.target}`);
9
+ this.name = 'LockContentionError';
10
+ this.details = details;
11
+ }
12
+ }
13
+ export class LockOwnershipError extends Error {
14
+ details;
15
+ constructor(details) {
16
+ super(`Lost ${details.name} lock for ${details.target}`);
17
+ this.name = 'LockOwnershipError';
18
+ this.details = details;
19
+ }
20
+ }
21
+ export async function withLock(target, options, fn) {
22
+ const started = performance.now();
23
+ for (;;) {
24
+ const lock = await acquireRefreshFileLock({
25
+ path: target,
26
+ name: options.name,
27
+ ttlMs: options.ttlMs,
28
+ renew: options.renew ?? false,
29
+ });
30
+ if (lock) {
31
+ try {
32
+ return await fn(lock);
33
+ }
34
+ finally {
35
+ await lock.release();
36
+ }
37
+ }
38
+ const remaining = options.timeoutMs - (performance.now() - started);
39
+ if (remaining <= 0) {
40
+ throw new LockContentionError({
41
+ target,
42
+ name: options.name,
43
+ timeoutMs: options.timeoutMs,
44
+ });
45
+ }
46
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.ceil(remaining))));
47
+ }
48
+ }
@@ -0,0 +1,14 @@
1
+ import type { Level } from './engine.js';
2
+ export interface LogTestRecord {
3
+ channel: string;
4
+ level: Level;
5
+ message: string;
6
+ data?: unknown;
7
+ }
8
+ /** The engine delivers scrubbed records only; this sink never sees raw credentials. */
9
+ export type CaptureSink = (record: LogTestRecord) => void;
10
+ export declare function createCaptureSink(): {
11
+ records: LogTestRecord[];
12
+ sink: CaptureSink;
13
+ clear: () => void;
14
+ };
@@ -0,0 +1,13 @@
1
+ export function createCaptureSink() {
2
+ const records = [];
3
+ const sink = (record) => {
4
+ records.push(record);
5
+ };
6
+ return {
7
+ records,
8
+ sink,
9
+ clear: () => {
10
+ records.length = 0;
11
+ },
12
+ };
13
+ }
@@ -0,0 +1,44 @@
1
+ import type { CaptureSink } from './capture-sink.js';
2
+ import { type RedactionOptions } from './redact.js';
3
+ export type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace';
4
+ export interface InitLoggerOptions extends RedactionOptions {
5
+ captureSink?: CaptureSink;
6
+ /**
7
+ * Path of the file lines are appended to, or a function returning it.
8
+ *
9
+ * A function is what a host passes when its destination can move while the
10
+ * process runs — an operator or a test changing the variable that names the
11
+ * log file expects the next line to land in the new file, and a value
12
+ * captured once at init would keep writing to the old one.
13
+ */
14
+ file: string | (() => string);
15
+ /** Level floor applied when no `setLogLevel` call has overridden it. */
16
+ level?: Level | (() => Level | undefined);
17
+ }
18
+ /**
19
+ * Point the logger at a host's file and level. Idempotent: calling it again
20
+ * replaces both. A runtime level installed by `setLogLevel` is deliberately
21
+ * left alone, because it is the operator's explicit choice and outranks the
22
+ * floor a host computed at start-up.
23
+ */
24
+ export declare function initLogger(options: InitLoggerOptions): void;
25
+ export declare function setLogLevel(l: Level | undefined): void;
26
+ /**
27
+ * Write whatever is buffered. Safe to call synchronously from a process-exit
28
+ * handler, which is how each host drains the buffer on shutdown.
29
+ */
30
+ export declare function flushLogs(): void;
31
+ export declare function createLogger(channel: string): {
32
+ error: (m: string, d?: unknown) => void;
33
+ warn: (m: string, d?: unknown) => void;
34
+ info: (m: string, d?: unknown) => void;
35
+ debug: (m: string, d?: unknown) => void;
36
+ trace: (m: string, d?: unknown) => void;
37
+ };
38
+ export declare function flushForTest(): Promise<void>;
39
+ /**
40
+ * Return the logger to its uninitialised state. Only a test needs this: a
41
+ * single process runs every test file, so a file path left over from one test
42
+ * would keep a later "logger was never initialised" case writing lines.
43
+ */
44
+ export declare function resetLoggerForTest(): void;