@tikoci/quickchr 0.2.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,163 @@
1
+ /**
2
+ * Storage health and usage helpers for quickchr-managed files.
3
+ */
4
+
5
+ import { existsSync, lstatSync, readdirSync, statfsSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { formatDiskSize } from "./disk.ts";
8
+ import { getCacheDir, getDataDir, getMachinesDir } from "./state.ts";
9
+ import { QuickCHRError } from "./types.ts";
10
+
11
+ export const QUICKCHR_MIN_FREE_BYTES = 10 * 1024 ** 3;
12
+ export const QUICKCHR_WARN_FREE_BYTES = 20 * 1024 ** 3;
13
+
14
+ export interface QuickchrStorageReport {
15
+ label: ".local" | "data dir";
16
+ path: string;
17
+ statPath: string;
18
+ freeBytes: number;
19
+ quickchrBytes: number;
20
+ cacheBytes: number;
21
+ machinesBytes: number;
22
+ otherBytes: number;
23
+ recommendedFreeBytes: number;
24
+ warningFreeBytes: number;
25
+ status: "ok" | "warn" | "error";
26
+ }
27
+
28
+ function resolveStorageRoot(): { label: QuickchrStorageReport["label"]; path: string } {
29
+ if (process.env.QUICKCHR_DATA_DIR || process.platform === "win32") {
30
+ return { label: "data dir", path: getDataDir() };
31
+ }
32
+
33
+ const home = process.env.HOME || process.env.USERPROFILE || "";
34
+ if (!home) {
35
+ return { label: "data dir", path: getDataDir() };
36
+ }
37
+
38
+ return { label: ".local", path: join(home, ".local") };
39
+ }
40
+
41
+ function nearestExistingPath(path: string): string {
42
+ let current = path || ".";
43
+ while (!existsSync(current)) {
44
+ const parent = dirname(current);
45
+ if (parent === current) return ".";
46
+ current = parent;
47
+ }
48
+ return current;
49
+ }
50
+
51
+ function directorySize(path: string): number {
52
+ if (!existsSync(path)) return 0;
53
+
54
+ const stat = lstatSync(path);
55
+ if (!stat.isDirectory()) {
56
+ return stat.size;
57
+ }
58
+
59
+ let total = 0;
60
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
61
+ const fullPath = join(path, entry.name);
62
+ if (entry.isDirectory()) {
63
+ total += directorySize(fullPath);
64
+ } else {
65
+ try {
66
+ total += lstatSync(fullPath).size;
67
+ } catch {
68
+ // Skip entries that can't be stat'd (e.g. Windows named pipes throw EACCES)
69
+ }
70
+ }
71
+ }
72
+ return total;
73
+ }
74
+
75
+ function freeBytesForPath(path: string): number {
76
+ if (process.platform !== "win32") {
77
+ const result = Bun.spawnSync(["df", "-kP", path], {
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ if (result.exitCode === 0) {
82
+ const output = new TextDecoder().decode(result.stdout).trim().split("\n");
83
+ const dataLine = output[1];
84
+ if (dataLine) {
85
+ const columns = dataLine.trim().split(/\s+/);
86
+ const availableKb = Number.parseInt(columns[3] ?? "", 10);
87
+ if (Number.isFinite(availableKb)) {
88
+ return availableKb * 1024;
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ const fsStats = statfsSync(path);
95
+ return fsStats.bavail * (fsStats.bsize || 4096);
96
+ }
97
+
98
+ export function getQuickchrStorageReport(
99
+ recommendedFreeBytes: number = QUICKCHR_MIN_FREE_BYTES,
100
+ warningFreeBytes: number = QUICKCHR_WARN_FREE_BYTES,
101
+ ): QuickchrStorageReport {
102
+ const storageRoot = resolveStorageRoot();
103
+ const statPath = nearestExistingPath(storageRoot.path);
104
+ const freeBytes = freeBytesForPath(statPath);
105
+
106
+ const dataDir = getDataDir();
107
+ const cacheBytes = directorySize(getCacheDir());
108
+ const machinesBytes = directorySize(getMachinesDir());
109
+ const dataDirBytes = directorySize(dataDir);
110
+ const quickchrBytes = Math.max(dataDirBytes, cacheBytes + machinesBytes);
111
+ const otherBytes = Math.max(0, dataDirBytes - cacheBytes - machinesBytes);
112
+
113
+ let status: QuickchrStorageReport["status"] = "ok";
114
+ if (freeBytes < recommendedFreeBytes) {
115
+ status = "error";
116
+ } else if (freeBytes < warningFreeBytes) {
117
+ status = "warn";
118
+ }
119
+
120
+ return {
121
+ label: storageRoot.label,
122
+ path: storageRoot.path,
123
+ statPath,
124
+ freeBytes,
125
+ quickchrBytes,
126
+ cacheBytes,
127
+ machinesBytes,
128
+ otherBytes,
129
+ recommendedFreeBytes,
130
+ warningFreeBytes,
131
+ status,
132
+ };
133
+ }
134
+
135
+ export function formatQuickchrUsage(report: QuickchrStorageReport): string {
136
+ const parts = [
137
+ `cache ${formatDiskSize(report.cacheBytes)}`,
138
+ `machines ${formatDiskSize(report.machinesBytes)}`,
139
+ ];
140
+ if (report.otherBytes > 0) {
141
+ parts.push(`other ${formatDiskSize(report.otherBytes)}`);
142
+ }
143
+ return `quickchr uses ${formatDiskSize(report.quickchrBytes)} (${parts.join(", ")})`;
144
+ }
145
+
146
+ export function assertSufficientQuickchrStorage(
147
+ action: string,
148
+ minimumFreeBytes: number = QUICKCHR_MIN_FREE_BYTES,
149
+ ): QuickchrStorageReport {
150
+ const report = getQuickchrStorageReport(
151
+ minimumFreeBytes,
152
+ Math.max(QUICKCHR_WARN_FREE_BYTES, minimumFreeBytes),
153
+ );
154
+
155
+ if (report.freeBytes < minimumFreeBytes) {
156
+ throw new QuickCHRError(
157
+ "INSUFFICIENT_DISK_SPACE",
158
+ `Not enough free space to ${action} in ${report.path}: ${formatDiskSize(report.freeBytes)} available, need at least ${formatDiskSize(minimumFreeBytes)}. ${formatQuickchrUsage(report)}.`,
159
+ );
160
+ }
161
+
162
+ return report;
163
+ }