bwx 0.5.0 → 0.6.1
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.
- package/README.md +55 -10
- package/package.json +1 -1
- package/skill/bwx/SKILL.md +54 -8
- package/src/bw/encoding.ts +14 -3
- package/src/bw/fields.ts +117 -37
- package/src/bw/items.ts +8 -0
- package/src/bw/session.ts +42 -5
- package/src/bw/totp.ts +246 -0
- package/src/cli/commands/create.ts +9 -0
- package/src/cli/commands/edit.ts +35 -0
- package/src/cli/commands/get.ts +103 -4
- package/src/cli/input.ts +25 -1
- package/src/cli/items.ts +4 -2
- package/src/config/paths.ts +2 -0
- package/src/config/process-lock.ts +255 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { CliError, ExitCode } from "../cli/errors.ts";
|
|
11
|
+
import { formatDuration } from "../cli/duration.ts";
|
|
12
|
+
import { readPrivateFile, writePrivateFile } from "./secure-file.ts";
|
|
13
|
+
|
|
14
|
+
const OWNER_FILE = "owner.json";
|
|
15
|
+
const DEFAULT_POLL_MS = 50;
|
|
16
|
+
const SHARED_BITS = 0o077;
|
|
17
|
+
const UUID_RE =
|
|
18
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
19
|
+
|
|
20
|
+
interface LockOwner {
|
|
21
|
+
pid: number;
|
|
22
|
+
token: string;
|
|
23
|
+
acquiredAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProcessLockOptions {
|
|
27
|
+
path: string;
|
|
28
|
+
label: string;
|
|
29
|
+
waitMs: number;
|
|
30
|
+
pollMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Ownership {
|
|
34
|
+
path: string;
|
|
35
|
+
owner: LockOwner;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs a critical section under a cross-process lock. Publishing a populated
|
|
40
|
+
* directory by rename keeps contenders from observing a half-created lock;
|
|
41
|
+
* live owners are waited on, while dead-owner generations are safely detached.
|
|
42
|
+
*/
|
|
43
|
+
export async function withProcessLock<T>(
|
|
44
|
+
options: ProcessLockOptions,
|
|
45
|
+
fn: () => Promise<T>,
|
|
46
|
+
): Promise<T> {
|
|
47
|
+
const ownership = await acquire(options);
|
|
48
|
+
let bodyFailed = false;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
bodyFailed = true;
|
|
54
|
+
throw err;
|
|
55
|
+
} finally {
|
|
56
|
+
try {
|
|
57
|
+
release(ownership, options.label);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Preserve the operation's useful error if cleanup also failed.
|
|
60
|
+
if (!bodyFailed) throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function acquire(options: ProcessLockOptions): Promise<Ownership> {
|
|
66
|
+
const startedAt = performance.now();
|
|
67
|
+
let holder: LockOwner | null = null;
|
|
68
|
+
|
|
69
|
+
while (true) {
|
|
70
|
+
const ownership = tryCreate(options.path);
|
|
71
|
+
if (ownership) return ownership;
|
|
72
|
+
|
|
73
|
+
holder = readOwner(options.path, options.label);
|
|
74
|
+
if (!holder) continue;
|
|
75
|
+
|
|
76
|
+
if (!isProcessAlive(holder.pid)) {
|
|
77
|
+
if (detachAbandonedLock(options.path, holder, options.label)) continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const elapsed = performance.now() - startedAt;
|
|
81
|
+
if (elapsed >= options.waitMs) {
|
|
82
|
+
throw new CliError(
|
|
83
|
+
`Timed out after ${formatDuration(options.waitMs)} waiting for ${options.label} held by pid ${holder.pid}`,
|
|
84
|
+
ExitCode.Timeout,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const remaining = options.waitMs - elapsed;
|
|
89
|
+
await Bun.sleep(Math.min(options.pollMs ?? DEFAULT_POLL_MS, remaining));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tryCreate(path: string): Ownership | null {
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
95
|
+
|
|
96
|
+
const owner: LockOwner = {
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
token: randomUUID(),
|
|
99
|
+
acquiredAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
const candidate = `${path}.${owner.token}.tmp`;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(candidate, { mode: 0o700 });
|
|
105
|
+
chmodSync(candidate, 0o700);
|
|
106
|
+
writePrivateFile(
|
|
107
|
+
`${candidate}/${OWNER_FILE}`,
|
|
108
|
+
JSON.stringify(owner) + "\n",
|
|
109
|
+
);
|
|
110
|
+
try {
|
|
111
|
+
renameSync(candidate, path);
|
|
112
|
+
return { path, owner };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (pathExists(path)) return null;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
if (pathExists(candidate)) {
|
|
119
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readOwner(path: string, label: string): LockOwner | null {
|
|
125
|
+
let stat;
|
|
126
|
+
try {
|
|
127
|
+
stat = lstatSync(path);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (errorCode(err) === "ENOENT") return null;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
134
|
+
throw invalidLock(label, `${path} is not a regular directory`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
137
|
+
throw invalidLock(label, `${path} is owned by uid ${stat.uid}`);
|
|
138
|
+
}
|
|
139
|
+
if (stat.mode & SHARED_BITS) {
|
|
140
|
+
const mode = (stat.mode & 0o777).toString(8).padStart(3, "0");
|
|
141
|
+
throw invalidLock(label, `${path} has mode ${mode}, expected 700`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const read = readPrivateFile(`${path}/${OWNER_FILE}`);
|
|
145
|
+
if (read.kind !== "ok") {
|
|
146
|
+
const reason =
|
|
147
|
+
read.kind === "missing"
|
|
148
|
+
? `${path}/${OWNER_FILE} is missing`
|
|
149
|
+
: read.reason;
|
|
150
|
+
throw invalidLock(label, reason);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const owner = JSON.parse(read.text) as Partial<LockOwner>;
|
|
155
|
+
if (
|
|
156
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
157
|
+
(owner.pid ?? 0) <= 0 ||
|
|
158
|
+
typeof owner.token !== "string" ||
|
|
159
|
+
!UUID_RE.test(owner.token) ||
|
|
160
|
+
typeof owner.acquiredAt !== "string" ||
|
|
161
|
+
!Number.isFinite(Date.parse(owner.acquiredAt))
|
|
162
|
+
) {
|
|
163
|
+
throw new Error("invalid owner fields");
|
|
164
|
+
}
|
|
165
|
+
return owner as LockOwner;
|
|
166
|
+
} catch {
|
|
167
|
+
throw invalidLock(label, `${path}/${OWNER_FILE} is malformed`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function detachAbandonedLock(
|
|
172
|
+
path: string,
|
|
173
|
+
expected: LockOwner,
|
|
174
|
+
label: string,
|
|
175
|
+
): boolean {
|
|
176
|
+
// The generation-specific recovery lock prevents two stale observations from
|
|
177
|
+
// detaching a successor that appeared between inspection and rename.
|
|
178
|
+
const recoveryPath = `${path}.reap.${expected.token}`;
|
|
179
|
+
const recovery = tryCreate(recoveryPath);
|
|
180
|
+
if (!recovery) {
|
|
181
|
+
const recoveryOwner = readOwner(recoveryPath, `${label} recovery lock`);
|
|
182
|
+
if (recoveryOwner && !isProcessAlive(recoveryOwner.pid)) {
|
|
183
|
+
detachAbandonedLock(
|
|
184
|
+
recoveryPath,
|
|
185
|
+
recoveryOwner,
|
|
186
|
+
`${label} recovery lock`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let abandoned: string | null = null;
|
|
193
|
+
try {
|
|
194
|
+
const current = readOwner(path, label);
|
|
195
|
+
if (
|
|
196
|
+
!current ||
|
|
197
|
+
current.token !== expected.token ||
|
|
198
|
+
isProcessAlive(current.pid)
|
|
199
|
+
) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
abandoned = `${path}.${expected.token}.abandoned`;
|
|
204
|
+
try {
|
|
205
|
+
renameSync(path, abandoned);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (errorCode(err) === "ENOENT") return false;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Cleanup names the detached generation, never a successor at the lock path.
|
|
212
|
+
rmSync(abandoned, { recursive: true, force: true });
|
|
213
|
+
return true;
|
|
214
|
+
} finally {
|
|
215
|
+
release(recovery, `${label} recovery lock`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function release(ownership: Ownership, label: string): void {
|
|
220
|
+
const current = readOwner(ownership.path, label);
|
|
221
|
+
if (!current || current.token !== ownership.owner.token) {
|
|
222
|
+
throw invalidLock(label, "ownership changed before release");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const released = `${ownership.path}.${ownership.owner.token}.released`;
|
|
226
|
+
renameSync(ownership.path, released);
|
|
227
|
+
rmSync(released, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isProcessAlive(pid: number): boolean {
|
|
231
|
+
try {
|
|
232
|
+
process.kill(pid, 0);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return errorCode(err) !== "ESRCH";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pathExists(path: string): boolean {
|
|
240
|
+
try {
|
|
241
|
+
lstatSync(path);
|
|
242
|
+
return true;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (errorCode(err) === "ENOENT") return false;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function errorCode(err: unknown): string | undefined {
|
|
250
|
+
return (err as NodeJS.ErrnoException)?.code;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function invalidLock(label: string, reason: string): CliError {
|
|
254
|
+
return new CliError(`Unsafe or corrupt ${label}: ${reason}`, ExitCode.Config);
|
|
255
|
+
}
|