ai-spend-agent 0.6.1 → 0.7.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.
@@ -0,0 +1,1100 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmod, link, lstat, mkdir, open, readdir, rename, rm, unlink } from "node:fs/promises";
3
+ import { platform as hostPlatform } from "node:os";
4
+ import { dirname, posix, win32 } from "node:path";
5
+ import { TextDecoder } from "node:util";
6
+ const RECEIPT_KIND = "aibill.statusline_install_receipt";
7
+ const RECEIPT_VERSION = 1;
8
+ const MAX_SETTINGS_BYTES = 1024 * 1024;
9
+ const MAX_RECEIPT_BYTES = 256 * 1024;
10
+ const MAX_RUNNER_BYTES = 1024 * 1024;
11
+ const MAX_LOCK_BYTES = 4096;
12
+ const MAX_JSON_DEPTH = 100;
13
+ const MAX_JSON_NODES = 100_000;
14
+ const LOCK_STALE_AFTER_MS = 30 * 60 * 1000;
15
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
16
+ export const aibillStatuslineRefreshIntervalSeconds = 30;
17
+ export class StatuslineInstallerError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.name = "StatuslineInstallerError";
22
+ this.code = code;
23
+ }
24
+ }
25
+ class IncompleteStatuslineTransactionError extends StatuslineInstallerError {
26
+ retainRecoveryBackups = true;
27
+ constructor() {
28
+ super("concurrent-edit", "The installer transaction was interrupted by a concurrent edit and exact rollback was not possible; recovery backups were retained.");
29
+ this.name = "IncompleteStatuslineTransactionError";
30
+ }
31
+ }
32
+ export function resolveStatuslinePaths(homeDir, cwd, platform = normalizePlatform(hostPlatform()), options = {}) {
33
+ const path = pathFor(platform);
34
+ const stateDirectory = path.join(homeDir, ".aibill");
35
+ const programFilesDirectory = options.programFilesDir
36
+ ?? (platform === "win32" && normalizePlatform(hostPlatform()) === "win32" ? process.env.ProgramFiles : undefined)
37
+ ?? "C:\\Program Files";
38
+ const managedDirectory = platform === "darwin"
39
+ ? path.join(path.parse(homeDir).root, "Library", "Application Support", "ClaudeCode")
40
+ : platform === "win32"
41
+ ? path.join(programFilesDirectory, "ClaudeCode")
42
+ : path.join(path.parse(homeDir).root, "etc", "claude-code");
43
+ return {
44
+ settingsPath: path.join(homeDir, ".claude", "settings.json"),
45
+ runnerPath: path.join(stateDirectory, "bin", "statusline.mjs"),
46
+ receiptPath: path.join(stateDirectory, "statusline-install-receipt.json"),
47
+ backupDirectory: path.join(stateDirectory, "backups"),
48
+ lockPath: path.join(stateDirectory, "statusline-install.lock"),
49
+ projectSettingsPath: path.join(cwd, ".claude", "settings.json"),
50
+ localSettingsPath: path.join(cwd, ".claude", "settings.local.json"),
51
+ managedSettingsPath: path.join(managedDirectory, "managed-settings.json"),
52
+ managedDropInDirectory: path.join(managedDirectory, "managed-settings.d")
53
+ };
54
+ }
55
+ export function buildAibillStatusLineSetting(_platform = normalizePlatform(hostPlatform())) {
56
+ return {
57
+ type: "command",
58
+ command: "node ~/.aibill/bin/statusline.mjs",
59
+ refreshInterval: aibillStatuslineRefreshIntervalSeconds
60
+ };
61
+ }
62
+ export function manualStatuslineConfigSnippet(platform) {
63
+ return `${JSON.stringify({ statusLine: buildAibillStatusLineSetting(platform) }, null, 2)}\n`;
64
+ }
65
+ export async function installClaudeStatusline(options) {
66
+ const platform = options.platform ?? normalizePlatform(hostPlatform());
67
+ const path = pathFor(platform);
68
+ const paths = {
69
+ ...resolveStatuslinePaths(options.homeDir, options.cwd, platform, { programFilesDir: options.programFilesDir }),
70
+ ...options.pathOverrides
71
+ };
72
+ await ensurePrivateDirectory(dirname(paths.lockPath));
73
+ const installerLock = await acquireInstallerLock(paths.lockPath, new Date());
74
+ try {
75
+ const desired = buildAibillStatusLineSetting(platform);
76
+ const desiredDigest = digestJson(desired);
77
+ const sourceRunner = await resolveRunnerSource(options);
78
+ const sourceRunnerDigest = digestBytes(sourceRunner);
79
+ const existing = await readSettings(paths.settingsPath);
80
+ const runnerBefore = await readFileSnapshot(paths.runnerPath, MAX_RUNNER_BYTES, "unsafe-runner-source", true);
81
+ const receiptRead = await readReceiptIfPresent(paths, platform);
82
+ await assertNoEffectiveConflict(paths, existing.value, options.homeDir, options.cwd, platform, options.managedSettingsPaths);
83
+ const existingStatusLine = existing.value.statusLine;
84
+ if (receiptRead) {
85
+ const receipt = receiptRead.receipt;
86
+ await verifyReceiptBackups(receipt, paths, platform);
87
+ if (existingStatusLine === undefined || digestJson(existingStatusLine) !== receipt.installedStatusLineDigest) {
88
+ throw new StatuslineInstallerError("ownership-mismatch", "Claude statusLine changed after aibill installed it; refusing to overwrite the user's current value.");
89
+ }
90
+ if (runnerBefore.exists && digestBytes(runnerBefore.bytes) !== receipt.installedRunnerDigest) {
91
+ throw new StatuslineInstallerError("ownership-mismatch", "The installed aibill runner changed after installation; refusing an owned update.");
92
+ }
93
+ if (receipt.installedStatusLineDigest !== desiredDigest) {
94
+ throw new StatuslineInstallerError("invalid-receipt", "The ownership receipt does not describe the supported aibill command.");
95
+ }
96
+ if (runnerBefore.exists && sourceRunnerDigest === receipt.installedRunnerDigest) {
97
+ return {
98
+ action: "unchanged",
99
+ settingsPath: paths.settingsPath,
100
+ runnerPath: paths.runnerPath,
101
+ receiptPath: paths.receiptPath,
102
+ backupPath: receipt.backupPath
103
+ };
104
+ }
105
+ const updatedReceipt = {
106
+ ...receipt,
107
+ installedRunnerDigest: sourceRunnerDigest
108
+ };
109
+ const mutations = [
110
+ mutation(paths.runnerPath, runnerBefore, sourceRunner, runnerBefore.mode ?? 0o600, true, "ownership-mismatch")
111
+ ];
112
+ if (sourceRunnerDigest !== receipt.installedRunnerDigest) {
113
+ const receiptBytes = serializeBoundedJson(updatedReceipt, MAX_RECEIPT_BYTES, "invalid-receipt", "ownership receipt");
114
+ mutations.push(mutation(paths.receiptPath, receiptRead.snapshot, receiptBytes, 0o600, true, "invalid-receipt"));
115
+ }
116
+ await applyMutations(mutations, options, [{ path: paths.settingsPath, before: existing, code: "ownership-mismatch" }], [installerLock.assertOwned]);
117
+ return {
118
+ action: "installed",
119
+ settingsPath: paths.settingsPath,
120
+ runnerPath: paths.runnerPath,
121
+ receiptPath: paths.receiptPath,
122
+ backupPath: receipt.backupPath
123
+ };
124
+ }
125
+ if (existingStatusLine !== undefined && !options.replace) {
126
+ throw new StatuslineInstallerError("statusline-conflict", "Claude user settings already contain an unowned statusLine; rerun with explicit replacement enabled.");
127
+ }
128
+ if (runnerBefore.exists && !options.replace) {
129
+ throw new StatuslineInstallerError("statusline-conflict", "The aibill runner path already contains an unowned file; rerun with explicit replacement enabled.");
130
+ }
131
+ const now = options.now ?? new Date();
132
+ const backupPath = backupFilePath(paths.backupDirectory, existing.bytes, now, path);
133
+ const runnerBackupPath = runnerBefore.exists
134
+ ? runnerBackupFilePath(paths.backupDirectory, runnerBefore.bytes, now, path)
135
+ : undefined;
136
+ const nextSettings = cloneJson(existing.value);
137
+ nextSettings.statusLine = desired;
138
+ const nextSettingsBytes = serializeBoundedJson(nextSettings, MAX_SETTINGS_BYTES, "unsafe-settings-file", "Claude settings");
139
+ const installReceipt = {
140
+ kind: RECEIPT_KIND,
141
+ version: RECEIPT_VERSION,
142
+ installedAt: validIso(now),
143
+ receiptPath: paths.receiptPath,
144
+ settingsPath: paths.settingsPath,
145
+ runnerPath: paths.runnerPath,
146
+ backupPath,
147
+ settingsBackupDigest: digestBytes(existing.bytes),
148
+ priorFileExisted: existing.exists,
149
+ priorHadStatusLine: existingStatusLine !== undefined,
150
+ ...(existingStatusLine !== undefined ? { priorStatusLine: cloneJson(existingStatusLine) } : {}),
151
+ priorRunnerExisted: runnerBefore.exists,
152
+ ...(runnerBefore.exists && runnerBackupPath
153
+ ? {
154
+ runnerBackupPath,
155
+ priorRunnerDigest: digestBytes(runnerBefore.bytes),
156
+ priorRunnerMode: runnerBefore.mode ?? 0o600
157
+ }
158
+ : {}),
159
+ installedStatusLine: desired,
160
+ installedStatusLineDigest: desiredDigest,
161
+ installedRunnerDigest: sourceRunnerDigest
162
+ };
163
+ const receiptBytes = serializeBoundedJson(installReceipt, MAX_RECEIPT_BYTES, "invalid-receipt", "ownership receipt");
164
+ const createdBackups = [];
165
+ try {
166
+ await installerLock.assertOwned();
167
+ await createExactBackup(backupPath, existing.bytes);
168
+ createdBackups.push({ path: backupPath, digest: digestBytes(existing.bytes) });
169
+ if (runnerBefore.exists && runnerBackupPath) {
170
+ await createExactBackup(runnerBackupPath, runnerBefore.bytes);
171
+ createdBackups.push({ path: runnerBackupPath, digest: digestBytes(runnerBefore.bytes) });
172
+ }
173
+ await applyMutations([
174
+ mutation(paths.runnerPath, runnerBefore, sourceRunner, 0o600, true, "concurrent-edit"),
175
+ mutation(paths.receiptPath, missingSnapshot(), receiptBytes, 0o600, true, "concurrent-edit"),
176
+ mutation(paths.settingsPath, existing, nextSettingsBytes, existing.mode ?? 0o600, false, "concurrent-edit")
177
+ ], options, [], [installerLock.assertOwned]);
178
+ }
179
+ catch (error) {
180
+ if (!(error instanceof IncompleteStatuslineTransactionError)) {
181
+ for (const backup of createdBackups.reverse()) {
182
+ await removeOwnedFile(backup.path, backup.digest).catch(() => undefined);
183
+ }
184
+ }
185
+ throw error;
186
+ }
187
+ return {
188
+ action: "installed",
189
+ settingsPath: paths.settingsPath,
190
+ runnerPath: paths.runnerPath,
191
+ receiptPath: paths.receiptPath,
192
+ backupPath
193
+ };
194
+ }
195
+ finally {
196
+ await installerLock.release();
197
+ }
198
+ }
199
+ export async function uninstallClaudeStatusline(options) {
200
+ const platform = options.platform ?? normalizePlatform(hostPlatform());
201
+ const paths = {
202
+ ...resolveStatuslinePaths(options.homeDir, options.cwd, platform, { programFilesDir: options.programFilesDir }),
203
+ ...options.pathOverrides
204
+ };
205
+ await ensurePrivateDirectory(dirname(paths.lockPath));
206
+ const installerLock = await acquireInstallerLock(paths.lockPath, new Date());
207
+ try {
208
+ const receiptRead = await readRequiredReceipt(paths, platform);
209
+ const receipt = receiptRead.receipt;
210
+ const backups = await verifyReceiptBackups(receipt, paths, platform);
211
+ const existing = await readSettings(paths.settingsPath);
212
+ if (existing.value.statusLine === undefined) {
213
+ throw new StatuslineInstallerError("ownership-mismatch", "Claude user settings no longer contain the statusLine owned by aibill.");
214
+ }
215
+ if (digestJson(existing.value.statusLine) !== receipt.installedStatusLineDigest) {
216
+ throw new StatuslineInstallerError("ownership-mismatch", "Claude statusLine changed after aibill installed it; refusing to overwrite the user's current value.");
217
+ }
218
+ const runnerCurrent = await readFileSnapshot(paths.runnerPath, MAX_RUNNER_BYTES, "ownership-mismatch", true);
219
+ const runnerStillOwned = runnerCurrent.exists && digestBytes(runnerCurrent.bytes) === receipt.installedRunnerDigest;
220
+ if (!runnerStillOwned && receipt.priorRunnerExisted) {
221
+ throw new StatuslineInstallerError("ownership-mismatch", "The installed runner changed; refusing to overwrite it with the pre-installation runner.");
222
+ }
223
+ const nextSettings = cloneJson(existing.value);
224
+ if (receipt.priorHadStatusLine) {
225
+ nextSettings.statusLine = cloneJson(receipt.priorStatusLine);
226
+ }
227
+ else {
228
+ delete nextSettings.statusLine;
229
+ }
230
+ const removeSettingsFile = !receipt.priorFileExisted && Object.keys(nextSettings).length === 0;
231
+ const nextSettingsBytes = removeSettingsFile
232
+ ? undefined
233
+ : serializeBoundedJson(nextSettings, MAX_SETTINGS_BYTES, "unsafe-settings-file", "Claude settings");
234
+ const settingsMutation = mutation(paths.settingsPath, existing, nextSettingsBytes, existing.mode ?? 0o600, false, "concurrent-edit");
235
+ let runnerMutation;
236
+ let runnerRemoved = false;
237
+ let runnerAction;
238
+ const warnings = [];
239
+ if (runnerStillOwned) {
240
+ if (receipt.priorRunnerExisted) {
241
+ runnerMutation = mutation(paths.runnerPath, runnerCurrent, backups.runner?.bytes, receipt.priorRunnerMode ?? 0o600, true, "ownership-mismatch");
242
+ runnerAction = "restored";
243
+ }
244
+ else {
245
+ runnerMutation = mutation(paths.runnerPath, runnerCurrent, undefined, undefined, true, "ownership-mismatch");
246
+ runnerRemoved = true;
247
+ runnerAction = "removed";
248
+ }
249
+ }
250
+ else if (runnerCurrent.exists) {
251
+ warnings.push("The installed runner changed after installation and was preserved.");
252
+ runnerAction = "preserved-modified";
253
+ }
254
+ else {
255
+ warnings.push("The installed runner is missing; no runner file was removed.");
256
+ runnerAction = "already-missing";
257
+ }
258
+ const mutations = [
259
+ settingsMutation,
260
+ mutation(paths.receiptPath, receiptRead.snapshot, undefined, undefined, true, "invalid-receipt")
261
+ ];
262
+ if (runnerMutation)
263
+ mutations.push(runnerMutation);
264
+ await applyMutations(mutations, options, [], [installerLock.assertOwned]);
265
+ await removeOwnedFile(receipt.backupPath, receipt.settingsBackupDigest).catch(() => undefined);
266
+ if (receipt.priorRunnerExisted && receipt.runnerBackupPath && receipt.priorRunnerDigest) {
267
+ await removeOwnedFile(receipt.runnerBackupPath, receipt.priorRunnerDigest).catch(() => undefined);
268
+ }
269
+ return {
270
+ action: "uninstalled",
271
+ settingsPath: paths.settingsPath,
272
+ runnerPath: paths.runnerPath,
273
+ runnerRemoved,
274
+ runnerAction,
275
+ statusLineAction: receipt.priorHadStatusLine ? "restored-prior" : "removed",
276
+ warnings
277
+ };
278
+ }
279
+ finally {
280
+ await installerLock.release();
281
+ }
282
+ }
283
+ async function assertNoEffectiveConflict(paths, userSettings, homeDir, cwd, platform, explicitManagedPaths) {
284
+ const managedPaths = explicitManagedPaths ?? await discoverManagedSettingsFiles(paths, platform);
285
+ const managedSettings = await readSettingsLayers(managedPaths);
286
+ const projectPaths = await discoverProjectSettingsFiles(paths, homeDir, cwd, platform);
287
+ const projectSettings = await readSettingsLayers(projectPaths.project);
288
+ const localSettings = await readSettingsLayers(projectPaths.local);
289
+ const shadow = firstStatusLineScope([
290
+ ["locally visible managed", managedSettings],
291
+ ["local", localSettings],
292
+ ["project", projectSettings]
293
+ ]);
294
+ if (shadow) {
295
+ throw new StatuslineInstallerError("settings-shadowed", `Claude ${shadow} settings define statusLine and would shadow the user-scope aibill installation.`);
296
+ }
297
+ const effectiveDisableAllHooks = firstDefinedBoolean([
298
+ ...managedSettings.slice().reverse(),
299
+ ...localSettings,
300
+ ...projectSettings,
301
+ userSettings
302
+ ], "disableAllHooks");
303
+ if (effectiveDisableAllHooks === true) {
304
+ throw new StatuslineInstallerError("hooks-disabled", "Claude's effective disableAllHooks setting is true, so a status line cannot run.");
305
+ }
306
+ }
307
+ async function discoverProjectSettingsFiles(paths, homeDir, cwd, platform) {
308
+ const path = pathFor(platform);
309
+ const project = [];
310
+ const local = [];
311
+ const seen = new Set();
312
+ const add = (list, candidate) => {
313
+ const key = normalizedPathKey(candidate, platform);
314
+ if (key === normalizedPathKey(paths.settingsPath, platform) || seen.has(key))
315
+ return;
316
+ seen.add(key);
317
+ list.push(candidate);
318
+ };
319
+ add(local, paths.localSettingsPath);
320
+ add(project, paths.projectSettingsPath);
321
+ const resolvedHome = path.resolve(homeDir);
322
+ let current = path.resolve(cwd);
323
+ while (true) {
324
+ if (normalizedPathKey(current, platform) === normalizedPathKey(resolvedHome, platform))
325
+ break;
326
+ add(local, path.join(current, ".claude", "settings.local.json"));
327
+ add(project, path.join(current, ".claude", "settings.json"));
328
+ const parent = path.dirname(current);
329
+ if (parent === current)
330
+ break;
331
+ current = parent;
332
+ }
333
+ return { project, local };
334
+ }
335
+ async function discoverManagedSettingsFiles(paths, platform) {
336
+ const files = [];
337
+ if (await fileExists(paths.managedSettingsPath))
338
+ files.push(paths.managedSettingsPath);
339
+ try {
340
+ const entries = await readdir(paths.managedDropInDirectory, { withFileTypes: true });
341
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".json")).sort((a, b) => a.name.localeCompare(b.name))) {
342
+ files.push(pathFor(platform).join(paths.managedDropInDirectory, entry.name));
343
+ }
344
+ }
345
+ catch (error) {
346
+ if (!isMissing(error))
347
+ throw error;
348
+ }
349
+ return files;
350
+ }
351
+ async function readSettingsLayers(paths) {
352
+ const layers = [];
353
+ for (const path of paths) {
354
+ const read = await readOptionalSettings(path);
355
+ if (read)
356
+ layers.push(read);
357
+ }
358
+ return layers;
359
+ }
360
+ async function readOptionalSettings(path) {
361
+ if (!await fileExists(path))
362
+ return undefined;
363
+ return (await readSettings(path)).value;
364
+ }
365
+ function firstStatusLineScope(layers) {
366
+ for (const [scope, settings] of layers) {
367
+ if (settings.some((value) => value.statusLine !== undefined))
368
+ return scope;
369
+ }
370
+ return undefined;
371
+ }
372
+ function firstDefinedBoolean(settings, key) {
373
+ for (const value of settings) {
374
+ if (typeof value[key] === "boolean")
375
+ return value[key];
376
+ }
377
+ return undefined;
378
+ }
379
+ async function readSettings(path) {
380
+ const snapshot = await readFileSnapshot(path, MAX_SETTINGS_BYTES, "unsafe-settings-file", true);
381
+ if (!snapshot.exists)
382
+ return { ...snapshot, raw: "", value: {} };
383
+ const raw = decodeUtf8Strict(snapshot.bytes, "invalid-settings-json", "Claude settings are not valid UTF-8; no changes were made.");
384
+ const parsed = parseJson(raw, "invalid-settings-json", "Claude settings are not strict finite JSON; no changes were made.");
385
+ if (!isJsonObject(parsed)) {
386
+ throw new StatuslineInstallerError("invalid-settings-json", "Claude settings must contain one JSON object; no changes were made.");
387
+ }
388
+ return { ...snapshot, raw, value: parsed };
389
+ }
390
+ async function readFileSnapshot(path, maxBytes, code, allowMissing) {
391
+ let before;
392
+ try {
393
+ before = await lstat(path);
394
+ }
395
+ catch (error) {
396
+ if (allowMissing && isMissing(error))
397
+ return missingSnapshot();
398
+ throw error;
399
+ }
400
+ if (!before.isFile() || before.isSymbolicLink()) {
401
+ throw new StatuslineInstallerError(code, "Refusing a non-regular or symbolic-link file.");
402
+ }
403
+ if (before.size > maxBytes) {
404
+ throw new StatuslineInstallerError(code, "A file exceeds the installer's safe size limit.");
405
+ }
406
+ const handle = await open(path, "r");
407
+ try {
408
+ const openedBefore = await handle.stat();
409
+ if (!openedBefore.isFile() || openedBefore.dev !== before.dev || openedBefore.ino !== before.ino) {
410
+ throw new StatuslineInstallerError(code, "A file changed identity while it was being read.");
411
+ }
412
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
413
+ let offset = 0;
414
+ while (offset < buffer.byteLength) {
415
+ const result = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
416
+ if (result.bytesRead === 0)
417
+ break;
418
+ offset += result.bytesRead;
419
+ }
420
+ if (offset > maxBytes) {
421
+ throw new StatuslineInstallerError(code, "A file exceeds the installer's safe size limit.");
422
+ }
423
+ const bytes = buffer.subarray(0, offset);
424
+ const openedAfter = await handle.stat();
425
+ const after = await lstat(path);
426
+ if (bytes.byteLength > maxBytes
427
+ || openedAfter.dev !== openedBefore.dev
428
+ || openedAfter.ino !== openedBefore.ino
429
+ || openedAfter.mode !== openedBefore.mode
430
+ || openedAfter.size !== bytes.byteLength
431
+ || after.dev !== openedBefore.dev
432
+ || after.ino !== openedBefore.ino
433
+ || after.mode !== openedBefore.mode) {
434
+ throw new StatuslineInstallerError(code, "A file changed while it was being read.");
435
+ }
436
+ return {
437
+ exists: true,
438
+ bytes,
439
+ mode: openedAfter.mode & 0o777,
440
+ dev: openedAfter.dev,
441
+ ino: openedAfter.ino
442
+ };
443
+ }
444
+ finally {
445
+ await handle.close();
446
+ }
447
+ }
448
+ async function readSafeRunnerSource(path) {
449
+ try {
450
+ const snapshot = await readFileSnapshot(path, MAX_RUNNER_BYTES, "unsafe-runner-source", false);
451
+ if (snapshot.bytes.byteLength === 0) {
452
+ throw new StatuslineInstallerError("unsafe-runner-source", "The statusline runner source is empty.");
453
+ }
454
+ return snapshot.bytes;
455
+ }
456
+ catch (error) {
457
+ if (error instanceof StatuslineInstallerError)
458
+ throw error;
459
+ throw new StatuslineInstallerError("unsafe-runner-source", "The statusline runner source could not be read safely.");
460
+ }
461
+ }
462
+ async function resolveRunnerSource(options) {
463
+ if (options.runnerContents !== undefined && options.runnerSourcePath !== undefined) {
464
+ throw new StatuslineInstallerError("unsafe-runner-source", "Provide either statusline runner bytes or a runner source path, not both.");
465
+ }
466
+ if (options.runnerContents !== undefined) {
467
+ const bytes = typeof options.runnerContents === "string"
468
+ ? Buffer.from(options.runnerContents, "utf8")
469
+ : Uint8Array.from(options.runnerContents);
470
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_RUNNER_BYTES) {
471
+ throw new StatuslineInstallerError("unsafe-runner-source", "The statusline runner is empty or exceeds its safe size limit.");
472
+ }
473
+ return bytes;
474
+ }
475
+ if (options.runnerSourcePath !== undefined)
476
+ return readSafeRunnerSource(options.runnerSourcePath);
477
+ throw new StatuslineInstallerError("unsafe-runner-source", "No standalone statusline runner was provided.");
478
+ }
479
+ async function readReceiptIfPresent(paths, platform) {
480
+ try {
481
+ const snapshot = await readFileSnapshot(paths.receiptPath, MAX_RECEIPT_BYTES, "invalid-receipt", true);
482
+ if (!snapshot.exists)
483
+ return undefined;
484
+ const raw = decodeUtf8Strict(snapshot.bytes, "invalid-receipt", "The aibill ownership receipt is not valid UTF-8.");
485
+ return { receipt: parseReceipt(raw, paths, platform), snapshot };
486
+ }
487
+ catch (error) {
488
+ if (error instanceof StatuslineInstallerError)
489
+ throw error;
490
+ throw new StatuslineInstallerError("invalid-receipt", "The aibill statusline ownership receipt could not be read safely.");
491
+ }
492
+ }
493
+ async function readRequiredReceipt(paths, platform) {
494
+ const receipt = await readReceiptIfPresent(paths, platform);
495
+ if (!receipt) {
496
+ throw new StatuslineInstallerError("missing-ownership", "No aibill statusline ownership receipt exists; refusing to uninstall.");
497
+ }
498
+ return receipt;
499
+ }
500
+ function parseReceipt(raw, paths, platform) {
501
+ const value = parseJson(raw, "invalid-receipt", "The aibill statusline ownership receipt is malformed.");
502
+ if (!isJsonObject(value))
503
+ throw invalidReceiptShape();
504
+ const required = [
505
+ "kind", "version", "installedAt", "receiptPath", "settingsPath", "runnerPath", "backupPath",
506
+ "settingsBackupDigest", "priorFileExisted", "priorHadStatusLine", "priorRunnerExisted",
507
+ "installedStatusLine", "installedStatusLineDigest", "installedRunnerDigest"
508
+ ];
509
+ const optional = [];
510
+ if (value.priorHadStatusLine === true)
511
+ optional.push("priorStatusLine");
512
+ if (value.priorRunnerExisted === true)
513
+ optional.push("runnerBackupPath", "priorRunnerDigest", "priorRunnerMode");
514
+ const allowed = new Set([...required, ...optional]);
515
+ if (required.some((key) => !(key in value)) || Object.keys(value).some((key) => !allowed.has(key))) {
516
+ throw invalidReceiptShape();
517
+ }
518
+ if (value.kind !== RECEIPT_KIND
519
+ || value.version !== RECEIPT_VERSION
520
+ || !isIsoTimestamp(value.installedAt)
521
+ || typeof value.receiptPath !== "string"
522
+ || typeof value.settingsPath !== "string"
523
+ || typeof value.runnerPath !== "string"
524
+ || typeof value.backupPath !== "string"
525
+ || !isDigest(value.settingsBackupDigest)
526
+ || typeof value.priorFileExisted !== "boolean"
527
+ || typeof value.priorHadStatusLine !== "boolean"
528
+ || typeof value.priorRunnerExisted !== "boolean"
529
+ || !isAibillStatusLineSetting(value.installedStatusLine)
530
+ || !isDigest(value.installedStatusLineDigest)
531
+ || !isDigest(value.installedRunnerDigest)) {
532
+ throw invalidReceiptShape();
533
+ }
534
+ if (value.priorHadStatusLine !== ("priorStatusLine" in value)
535
+ || (!value.priorFileExisted && value.priorHadStatusLine)) {
536
+ throw new StatuslineInstallerError("invalid-receipt", "The ownership receipt has incoherent prior-settings flags.");
537
+ }
538
+ if (value.priorRunnerExisted) {
539
+ if (typeof value.runnerBackupPath !== "string"
540
+ || !isDigest(value.priorRunnerDigest)
541
+ || !Number.isInteger(value.priorRunnerMode)
542
+ || value.priorRunnerMode < 0
543
+ || value.priorRunnerMode > 0o777) {
544
+ throw new StatuslineInstallerError("invalid-receipt", "The ownership receipt has incoherent prior-runner fields.");
545
+ }
546
+ }
547
+ if (!samePath(value.receiptPath, paths.receiptPath, platform)
548
+ || !samePath(value.settingsPath, paths.settingsPath, platform)
549
+ || !samePath(value.runnerPath, paths.runnerPath, platform)
550
+ || !isConfinedPath(value.backupPath, paths.backupDirectory, platform)
551
+ || (value.priorRunnerExisted && !isConfinedPath(value.runnerBackupPath, paths.backupDirectory, platform))) {
552
+ throw new StatuslineInstallerError("invalid-receipt", "The ownership receipt contains unexpected or unconfined paths.");
553
+ }
554
+ const desired = buildAibillStatusLineSetting(platform);
555
+ if (digestJson(value.installedStatusLine) !== value.installedStatusLineDigest
556
+ || digestJson(desired) !== value.installedStatusLineDigest
557
+ || canonicalJson(value.installedStatusLine) !== canonicalJson(desired)) {
558
+ throw new StatuslineInstallerError("invalid-receipt", "The ownership receipt failed its command or digest check.");
559
+ }
560
+ return value;
561
+ }
562
+ async function verifyReceiptBackups(receipt, paths, platform) {
563
+ await assertSafeDirectory(paths.backupDirectory, "invalid-receipt", false);
564
+ if (!isConfinedPath(receipt.backupPath, paths.backupDirectory, platform))
565
+ throw invalidReceiptShape();
566
+ const settings = await readRequiredBackup(receipt.backupPath, MAX_SETTINGS_BYTES);
567
+ if (digestBytes(settings.bytes) !== receipt.settingsBackupDigest) {
568
+ throw new StatuslineInstallerError("invalid-receipt", "The exact settings backup failed its digest check.");
569
+ }
570
+ const path = pathFor(platform);
571
+ const installedAt = new Date(receipt.installedAt);
572
+ if (!samePath(receipt.backupPath, backupFilePath(paths.backupDirectory, settings.bytes, installedAt, path), platform)) {
573
+ throw new StatuslineInstallerError("invalid-receipt", "The settings backup path does not match its timestamp and digest.");
574
+ }
575
+ if (!receipt.priorFileExisted) {
576
+ if (settings.bytes.byteLength !== 0 || receipt.priorHadStatusLine) {
577
+ throw new StatuslineInstallerError("invalid-receipt", "The settings backup conflicts with the prior-file flags.");
578
+ }
579
+ }
580
+ else {
581
+ const raw = decodeUtf8Strict(settings.bytes, "invalid-receipt", "The exact settings backup is not valid UTF-8.");
582
+ const prior = parseJson(raw, "invalid-receipt", "The exact settings backup is not strict finite JSON.");
583
+ if (!isJsonObject(prior)
584
+ || (prior.statusLine !== undefined) !== receipt.priorHadStatusLine
585
+ || (receipt.priorHadStatusLine && canonicalJson(prior.statusLine) !== canonicalJson(receipt.priorStatusLine))) {
586
+ throw new StatuslineInstallerError("invalid-receipt", "The exact settings backup conflicts with the receipt's prior statusLine.");
587
+ }
588
+ }
589
+ if (!receipt.priorRunnerExisted)
590
+ return { settings };
591
+ const runner = await readRequiredBackup(receipt.runnerBackupPath, MAX_RUNNER_BYTES);
592
+ if (digestBytes(runner.bytes) !== receipt.priorRunnerDigest) {
593
+ throw new StatuslineInstallerError("invalid-receipt", "The exact runner backup failed its digest check.");
594
+ }
595
+ if (!samePath(receipt.runnerBackupPath, runnerBackupFilePath(paths.backupDirectory, runner.bytes, installedAt, path), platform)) {
596
+ throw new StatuslineInstallerError("invalid-receipt", "The runner backup path does not match its timestamp and digest.");
597
+ }
598
+ return { settings, runner };
599
+ }
600
+ async function readRequiredBackup(path, maxBytes) {
601
+ try {
602
+ return await readFileSnapshot(path, maxBytes, "invalid-receipt", false);
603
+ }
604
+ catch (error) {
605
+ if (error instanceof StatuslineInstallerError)
606
+ throw error;
607
+ throw new StatuslineInstallerError("invalid-receipt", "A required exact installer backup is missing or unreadable.");
608
+ }
609
+ }
610
+ function isAibillStatusLineSetting(value) {
611
+ return isJsonObject(value)
612
+ && Object.keys(value).length === 3
613
+ && value.type === "command"
614
+ && value.command === "node ~/.aibill/bin/statusline.mjs"
615
+ && value.refreshInterval === aibillStatuslineRefreshIntervalSeconds;
616
+ }
617
+ function mutation(path, before, after, mode, privateParent, mismatchCode) {
618
+ return { path, before, after, mode, privateParent, mismatchCode, committed: false };
619
+ }
620
+ async function applyMutations(mutations, hooks, guards = [], preCommitChecks = []) {
621
+ try {
622
+ for (const item of mutations) {
623
+ if (item.after !== undefined) {
624
+ item.temporaryPath = await prepareReplacement(item.path, item.after, item.mode ?? 0o600, item.privateParent);
625
+ }
626
+ else {
627
+ await assertSafeDirectory(dirname(item.path), item.mismatchCode, true);
628
+ }
629
+ }
630
+ await hooks.afterPrepare?.();
631
+ await hooks.beforeSettingsCommit?.();
632
+ for (const check of preCommitChecks)
633
+ await check();
634
+ for (const guard of guards)
635
+ await assertSnapshotCurrent(guard.path, guard.before, guard.code);
636
+ for (const [index, item] of mutations.entries()) {
637
+ await assertSnapshotCurrent(item.path, item.before, item.mismatchCode);
638
+ if (item.after === undefined) {
639
+ await unlink(item.path);
640
+ }
641
+ else {
642
+ if (!item.temporaryPath)
643
+ throw new Error("prepared replacement is missing");
644
+ await assertPreparedReplacement(item.temporaryPath, item.after, item.mode ?? 0o600, item.mismatchCode);
645
+ await rename(item.temporaryPath, item.path);
646
+ item.temporaryPath = undefined;
647
+ }
648
+ item.committed = true;
649
+ await assertCommittedMutation(item);
650
+ await hooks.afterMutationCommit?.(item.path, index);
651
+ }
652
+ }
653
+ catch (error) {
654
+ let rollbackFailed = false;
655
+ for (const item of mutations.slice().reverse()) {
656
+ if (!item.committed)
657
+ continue;
658
+ try {
659
+ await rollbackMutation(item);
660
+ }
661
+ catch {
662
+ rollbackFailed = true;
663
+ break;
664
+ }
665
+ }
666
+ if (rollbackFailed) {
667
+ throw new IncompleteStatuslineTransactionError();
668
+ }
669
+ throw error;
670
+ }
671
+ finally {
672
+ for (const item of mutations) {
673
+ if (item.temporaryPath)
674
+ await rm(item.temporaryPath, { force: true }).catch(() => undefined);
675
+ }
676
+ }
677
+ }
678
+ async function prepareReplacement(path, bytes, mode, privateParent) {
679
+ if (privateParent)
680
+ await ensurePrivateDirectory(dirname(path));
681
+ else
682
+ await ensureSettingsDirectory(dirname(path));
683
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
684
+ let handle;
685
+ try {
686
+ handle = await open(temporaryPath, "wx", mode);
687
+ await handle.writeFile(bytes);
688
+ await handle.chmod(mode);
689
+ await handle.sync();
690
+ await handle.close();
691
+ handle = undefined;
692
+ await assertPreparedReplacement(temporaryPath, bytes, mode, "concurrent-edit");
693
+ return temporaryPath;
694
+ }
695
+ catch (error) {
696
+ await handle?.close().catch(() => undefined);
697
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
698
+ throw error;
699
+ }
700
+ }
701
+ async function assertPreparedReplacement(path, bytes, mode, code) {
702
+ const snapshot = await readFileSnapshot(path, Math.max(bytes.byteLength, 1), code, false);
703
+ if (!bytesEqual(snapshot.bytes, bytes) || (process.platform !== "win32" && snapshot.mode !== mode)) {
704
+ throw new StatuslineInstallerError(code, "A prepared installer file failed its byte or mode check.");
705
+ }
706
+ }
707
+ async function assertCommittedMutation(item) {
708
+ const snapshot = await readFileSnapshot(item.path, Math.max(item.after?.byteLength ?? 1, 1), item.mismatchCode, item.after === undefined);
709
+ if (item.after === undefined) {
710
+ if (snapshot.exists)
711
+ throw new StatuslineInstallerError(item.mismatchCode, "A deletion did not commit safely.");
712
+ return;
713
+ }
714
+ if (!snapshot.exists
715
+ || !bytesEqual(snapshot.bytes, item.after)
716
+ || (process.platform !== "win32" && snapshot.mode !== (item.mode ?? 0o600))) {
717
+ throw new StatuslineInstallerError(item.mismatchCode, "A replacement did not commit safely.");
718
+ }
719
+ }
720
+ async function rollbackMutation(item) {
721
+ const current = await readFileSnapshot(item.path, Math.max(item.after?.byteLength ?? item.before.bytes.byteLength, 1), item.mismatchCode, true);
722
+ const stillOurs = item.after === undefined
723
+ ? !current.exists
724
+ : current.exists
725
+ && bytesEqual(current.bytes, item.after)
726
+ && (process.platform === "win32" || current.mode === (item.mode ?? 0o600));
727
+ if (!stillOurs) {
728
+ throw new StatuslineInstallerError(item.mismatchCode, "A committed installer file changed before rollback could restore it.");
729
+ }
730
+ if (!item.before.exists) {
731
+ if (current.exists)
732
+ await unlink(item.path);
733
+ return;
734
+ }
735
+ const temporaryPath = await prepareReplacement(item.path, item.before.bytes, item.before.mode ?? 0o600, item.privateParent);
736
+ try {
737
+ await assertSnapshotCurrent(item.path, current, item.mismatchCode);
738
+ await rename(temporaryPath, item.path);
739
+ }
740
+ finally {
741
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
742
+ }
743
+ }
744
+ async function assertSnapshotCurrent(path, before, code) {
745
+ const after = await readFileSnapshot(path, Math.max(before.bytes.byteLength, 1), code, true);
746
+ const same = after.exists === before.exists
747
+ && (!before.exists || (after.dev === before.dev
748
+ && after.ino === before.ino
749
+ && after.mode === before.mode
750
+ && bytesEqual(after.bytes, before.bytes)));
751
+ if (!same) {
752
+ throw new StatuslineInstallerError(code, "A destination changed after preparation; refusing to overwrite the newer file.");
753
+ }
754
+ }
755
+ async function createExactBackup(path, bytes) {
756
+ const temporaryPath = await prepareReplacement(path, bytes, 0o600, true);
757
+ let linked = false;
758
+ try {
759
+ await link(temporaryPath, path);
760
+ linked = true;
761
+ const backup = await readFileSnapshot(path, Math.max(bytes.byteLength, 1), "concurrent-edit", false);
762
+ if (!bytesEqual(backup.bytes, bytes) || (process.platform !== "win32" && backup.mode !== 0o600)) {
763
+ throw new StatuslineInstallerError("concurrent-edit", "An exact installer backup failed verification.");
764
+ }
765
+ }
766
+ catch (error) {
767
+ if (linked)
768
+ await rm(path, { force: true }).catch(() => undefined);
769
+ if (hasCode(error, "EEXIST")) {
770
+ throw new StatuslineInstallerError("concurrent-edit", "The exact installer backup path already exists; refusing to overwrite it.");
771
+ }
772
+ throw error;
773
+ }
774
+ finally {
775
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
776
+ }
777
+ }
778
+ async function removeOwnedFile(path, expectedDigest) {
779
+ const snapshot = await readFileSnapshot(path, Math.max(MAX_SETTINGS_BYTES, MAX_RUNNER_BYTES), "invalid-receipt", true);
780
+ if (snapshot.exists && digestBytes(snapshot.bytes) === expectedDigest)
781
+ await unlink(path);
782
+ }
783
+ async function acquireInstallerLock(path, now) {
784
+ const record = {
785
+ version: 1,
786
+ pid: process.pid,
787
+ createdAt: validIso(now),
788
+ token: randomBytes(24).toString("hex")
789
+ };
790
+ const bytes = serializeBoundedJson(record, MAX_LOCK_BYTES, "installer-busy", "installer lock");
791
+ let recovered = false;
792
+ while (true) {
793
+ let handle;
794
+ let createdIdentity;
795
+ try {
796
+ handle = await open(path, "wx", 0o600);
797
+ const opened = await handle.stat();
798
+ createdIdentity = { dev: opened.dev, ino: opened.ino };
799
+ await handle.writeFile(bytes);
800
+ await handle.chmod(0o600);
801
+ await handle.sync();
802
+ const info = await handle.stat();
803
+ await handle.close();
804
+ handle = undefined;
805
+ const owned = await readFileSnapshot(path, MAX_LOCK_BYTES, "installer-busy", false);
806
+ if (owned.dev !== info.dev || owned.ino !== info.ino || !bytesEqual(owned.bytes, bytes)) {
807
+ throw installerBusy();
808
+ }
809
+ const assertOwned = async () => {
810
+ const current = await readFileSnapshot(path, MAX_LOCK_BYTES, "installer-busy", true).catch(() => missingSnapshot());
811
+ if (!current.exists
812
+ || current.dev !== owned.dev
813
+ || current.ino !== owned.ino
814
+ || !bytesEqual(current.bytes, bytes))
815
+ throw installerBusy();
816
+ };
817
+ return {
818
+ assertOwned,
819
+ release: async () => {
820
+ try {
821
+ await assertOwned();
822
+ }
823
+ catch {
824
+ return;
825
+ }
826
+ await unlink(path).catch((error) => {
827
+ if (!isMissing(error))
828
+ throw error;
829
+ });
830
+ }
831
+ };
832
+ }
833
+ catch (error) {
834
+ await handle?.close().catch(() => undefined);
835
+ if (createdIdentity) {
836
+ const current = await readFileSnapshot(path, MAX_LOCK_BYTES, "installer-busy", true).catch(() => missingSnapshot());
837
+ if (current.exists && current.dev === createdIdentity.dev && current.ino === createdIdentity.ino) {
838
+ await unlink(path).catch(() => undefined);
839
+ }
840
+ }
841
+ if (!hasCode(error, "EEXIST") || recovered) {
842
+ if (hasCode(error, "EEXIST"))
843
+ throw installerBusy();
844
+ throw error;
845
+ }
846
+ if (!await recoverStaleLock(path, now))
847
+ throw installerBusy();
848
+ recovered = true;
849
+ }
850
+ }
851
+ }
852
+ async function recoverStaleLock(path, now) {
853
+ let snapshot;
854
+ try {
855
+ snapshot = await readFileSnapshot(path, MAX_LOCK_BYTES, "installer-busy", false);
856
+ }
857
+ catch {
858
+ return false;
859
+ }
860
+ let parsed;
861
+ try {
862
+ parsed = parseJson(decodeUtf8Strict(snapshot.bytes, "installer-busy", "The installer lock is not valid UTF-8."), "installer-busy", "The installer lock is malformed.");
863
+ }
864
+ catch {
865
+ return false;
866
+ }
867
+ if (!isJsonObject(parsed)
868
+ || Object.keys(parsed).sort().join(",") !== "createdAt,pid,token,version"
869
+ || parsed.version !== 1
870
+ || !Number.isSafeInteger(parsed.pid)
871
+ || parsed.pid <= 0
872
+ || !isIsoTimestamp(parsed.createdAt)
873
+ || typeof parsed.token !== "string"
874
+ || !/^[a-f0-9]{48}$/.test(parsed.token))
875
+ return false;
876
+ const age = now.getTime() - Date.parse(parsed.createdAt);
877
+ if (!Number.isFinite(age) || age < LOCK_STALE_AFTER_MS || isProcessAlive(parsed.pid))
878
+ return false;
879
+ try {
880
+ await assertSnapshotCurrent(path, snapshot, "installer-busy");
881
+ await unlink(path);
882
+ return true;
883
+ }
884
+ catch {
885
+ return false;
886
+ }
887
+ }
888
+ function isProcessAlive(pid) {
889
+ try {
890
+ process.kill(pid, 0);
891
+ return true;
892
+ }
893
+ catch (error) {
894
+ return !hasCode(error, "ESRCH");
895
+ }
896
+ }
897
+ async function ensurePrivateDirectory(path) {
898
+ try {
899
+ const existing = await lstat(path);
900
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
901
+ throw new StatuslineInstallerError("unsafe-settings-file", "The private aibill state path is not a regular directory.");
902
+ }
903
+ }
904
+ catch (error) {
905
+ if (!isMissing(error))
906
+ throw error;
907
+ await mkdir(path, { recursive: true, mode: 0o700 });
908
+ const created = await lstat(path);
909
+ if (!created.isDirectory() || created.isSymbolicLink()) {
910
+ throw new StatuslineInstallerError("unsafe-settings-file", "The private aibill state path could not be created safely.");
911
+ }
912
+ }
913
+ await chmod(path, 0o700);
914
+ }
915
+ async function ensureSettingsDirectory(path) {
916
+ try {
917
+ const info = await lstat(path);
918
+ if (!info.isDirectory() || info.isSymbolicLink()) {
919
+ throw new StatuslineInstallerError("unsafe-settings-file", "Claude's settings parent is not a regular directory.");
920
+ }
921
+ }
922
+ catch (error) {
923
+ if (!isMissing(error))
924
+ throw error;
925
+ await mkdir(path, { recursive: true, mode: 0o700 });
926
+ const created = await lstat(path);
927
+ if (!created.isDirectory() || created.isSymbolicLink()) {
928
+ throw new StatuslineInstallerError("unsafe-settings-file", "Claude's settings parent could not be created safely.");
929
+ }
930
+ }
931
+ }
932
+ async function assertSafeDirectory(path, code, allowMissing) {
933
+ try {
934
+ const info = await lstat(path);
935
+ if (!info.isDirectory() || info.isSymbolicLink()) {
936
+ throw new StatuslineInstallerError(code, "A parent directory is not a regular directory.");
937
+ }
938
+ }
939
+ catch (error) {
940
+ if (allowMissing && isMissing(error))
941
+ return;
942
+ throw error;
943
+ }
944
+ }
945
+ function backupFilePath(directory, bytes, now, path) {
946
+ const timestamp = validIso(now).replace(/[:.]/g, "-");
947
+ const suffix = digestBytes(bytes).slice(0, 12);
948
+ return path.join(directory, `claude-settings-${timestamp}-${suffix}.json`);
949
+ }
950
+ function runnerBackupFilePath(directory, bytes, now, path) {
951
+ const timestamp = validIso(now).replace(/[:.]/g, "-");
952
+ const suffix = digestBytes(bytes).slice(0, 12);
953
+ return path.join(directory, `statusline-runner-${timestamp}-${suffix}.mjs`);
954
+ }
955
+ function serializeBoundedJson(value, maxBytes, code, label) {
956
+ validateJsonTree(value, code, `${label} is not finite JSON.`);
957
+ let raw;
958
+ try {
959
+ raw = `${JSON.stringify(value, null, 2)}\n`;
960
+ }
961
+ catch {
962
+ throw new StatuslineInstallerError(code, `${label} could not be serialized safely.`);
963
+ }
964
+ const bytes = Buffer.from(raw, "utf8");
965
+ if (bytes.byteLength > maxBytes) {
966
+ throw new StatuslineInstallerError(code, `${label} exceeds the installer's safe serialized size limit.`);
967
+ }
968
+ return bytes;
969
+ }
970
+ function parseJson(raw, code, message) {
971
+ let parsed;
972
+ try {
973
+ parsed = JSON.parse(raw);
974
+ }
975
+ catch {
976
+ throw new StatuslineInstallerError(code, message);
977
+ }
978
+ validateJsonTree(parsed, code, message);
979
+ return parsed;
980
+ }
981
+ function validateJsonTree(value, code, message) {
982
+ const stack = [{ value, depth: 0 }];
983
+ let nodes = 0;
984
+ while (stack.length > 0) {
985
+ const current = stack.pop();
986
+ nodes += 1;
987
+ if (nodes > MAX_JSON_NODES || current.depth > MAX_JSON_DEPTH)
988
+ throw new StatuslineInstallerError(code, message);
989
+ if (current.value === null || typeof current.value === "string" || typeof current.value === "boolean")
990
+ continue;
991
+ if (typeof current.value === "number") {
992
+ if (!Number.isFinite(current.value))
993
+ throw new StatuslineInstallerError(code, message);
994
+ continue;
995
+ }
996
+ if (Array.isArray(current.value)) {
997
+ for (const item of current.value)
998
+ stack.push({ value: item, depth: current.depth + 1 });
999
+ continue;
1000
+ }
1001
+ if (isJsonObject(current.value)) {
1002
+ for (const item of Object.values(current.value))
1003
+ stack.push({ value: item, depth: current.depth + 1 });
1004
+ continue;
1005
+ }
1006
+ throw new StatuslineInstallerError(code, message);
1007
+ }
1008
+ }
1009
+ function decodeUtf8Strict(bytes, code, message) {
1010
+ try {
1011
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1012
+ }
1013
+ catch {
1014
+ throw new StatuslineInstallerError(code, message);
1015
+ }
1016
+ }
1017
+ function cloneJson(value) {
1018
+ validateJsonTree(value, "invalid-settings-json", "A JSON value is not finite.");
1019
+ return JSON.parse(JSON.stringify(value));
1020
+ }
1021
+ function digestJson(value) {
1022
+ validateJsonTree(value, "invalid-settings-json", "A JSON value is not finite.");
1023
+ return digestBytes(Buffer.from(canonicalJson(value)));
1024
+ }
1025
+ function digestBytes(value) {
1026
+ return createHash("sha256").update(value).digest("hex");
1027
+ }
1028
+ function canonicalJson(value) {
1029
+ if (Array.isArray(value))
1030
+ return `[${value.map(canonicalJson).join(",")}]`;
1031
+ if (isJsonObject(value)) {
1032
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
1033
+ }
1034
+ return JSON.stringify(value);
1035
+ }
1036
+ function isJsonObject(value) {
1037
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1038
+ }
1039
+ function pathFor(platform) {
1040
+ return platform === "win32" ? win32 : posix;
1041
+ }
1042
+ function normalizePlatform(platform) {
1043
+ return platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux";
1044
+ }
1045
+ function normalizedPathKey(value, platform) {
1046
+ const resolved = pathFor(platform).resolve(value);
1047
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
1048
+ }
1049
+ function samePath(left, right, platform) {
1050
+ return normalizedPathKey(left, platform) === normalizedPathKey(right, platform);
1051
+ }
1052
+ function isConfinedPath(candidate, directory, platform) {
1053
+ const path = pathFor(platform);
1054
+ const relative = path.relative(path.resolve(directory), path.resolve(candidate));
1055
+ return relative.length > 0 && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
1056
+ }
1057
+ function validIso(value) {
1058
+ if (!Number.isFinite(value.getTime()))
1059
+ throw new StatuslineInstallerError("invalid-receipt", "The installation time is invalid.");
1060
+ return value.toISOString();
1061
+ }
1062
+ function isIsoTimestamp(value) {
1063
+ if (typeof value !== "string")
1064
+ return false;
1065
+ const date = new Date(value);
1066
+ return Number.isFinite(date.getTime()) && date.toISOString() === value;
1067
+ }
1068
+ function isDigest(value) {
1069
+ return typeof value === "string" && SHA256_PATTERN.test(value);
1070
+ }
1071
+ function bytesEqual(left, right) {
1072
+ return Buffer.from(left).equals(Buffer.from(right));
1073
+ }
1074
+ function missingSnapshot() {
1075
+ return { exists: false, bytes: new Uint8Array() };
1076
+ }
1077
+ function invalidReceiptShape() {
1078
+ return new StatuslineInstallerError("invalid-receipt", "The aibill statusline ownership receipt has an unsupported shape.");
1079
+ }
1080
+ function installerBusy() {
1081
+ return new StatuslineInstallerError("installer-busy", "Another aibill statusline settings operation is already in progress.");
1082
+ }
1083
+ function hasCode(error, code) {
1084
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
1085
+ }
1086
+ function isMissing(error) {
1087
+ return hasCode(error, "ENOENT");
1088
+ }
1089
+ async function fileExists(path) {
1090
+ try {
1091
+ await lstat(path);
1092
+ return true;
1093
+ }
1094
+ catch (error) {
1095
+ if (isMissing(error))
1096
+ return false;
1097
+ throw error;
1098
+ }
1099
+ }
1100
+ //# sourceMappingURL=statuslineInstaller.js.map