u-foo 1.4.0 → 1.5.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,279 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { canonicalProjectRoot, buildProjectId, trimTrailingSlashes } = require("./projectId");
5
+ const { getUfooPaths } = require("../ufoo/paths");
6
+
7
+ const DEFAULT_STALE_TTL_MS = 30 * 1000;
8
+ const DEFAULT_TMP_CLEANUP_AGE_MS = 5 * 60 * 1000;
9
+
10
+ function ensureDir(dirPath) {
11
+ if (!fs.existsSync(dirPath)) {
12
+ fs.mkdirSync(dirPath, { recursive: true });
13
+ }
14
+ }
15
+
16
+ function canonicalizeForRecord(projectRoot) {
17
+ const input = String(projectRoot || "").trim();
18
+ if (!input) throw new Error("projectRoot is required");
19
+ try {
20
+ return canonicalProjectRoot(input);
21
+ } catch (err) {
22
+ if (!err || err.code === "ENOENT") {
23
+ // Keep stale records readable when project path was deleted or moved.
24
+ return trimTrailingSlashes(path.resolve(input));
25
+ }
26
+ // Unexpected IO errors should be visible, but keep fallback behavior.
27
+ // eslint-disable-next-line no-console
28
+ console.warn(`[projects] canonicalize fallback for ${input}: ${err.message || err}`);
29
+ // Keep stale records readable even when project path no longer exists.
30
+ return trimTrailingSlashes(path.resolve(input));
31
+ }
32
+ }
33
+
34
+ function resolveRuntimeDir(options = {}) {
35
+ if (options.runtimeDir) return options.runtimeDir;
36
+ return path.join(os.homedir(), ".ufoo", "projects", "runtime");
37
+ }
38
+
39
+ function normalizeIsoTimestamp(value, fallback = new Date().toISOString()) {
40
+ if (!value) return fallback;
41
+ const parsed = new Date(value);
42
+ if (Number.isNaN(parsed.getTime())) return fallback;
43
+ return parsed.toISOString();
44
+ }
45
+
46
+ function runtimeFilePathByProjectId(projectId, options = {}) {
47
+ const runtimeDir = resolveRuntimeDir(options);
48
+ return path.join(runtimeDir, `${projectId}.json`);
49
+ }
50
+
51
+ function runtimeFilePathByProjectRoot(projectRoot, options = {}) {
52
+ return runtimeFilePathByProjectId(buildProjectId(projectRoot), options);
53
+ }
54
+
55
+ function readJsonFileSafe(filePath) {
56
+ try {
57
+ const raw = fs.readFileSync(filePath, "utf8");
58
+ return JSON.parse(raw);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeJsonAtomic(filePath, data) {
65
+ ensureDir(path.dirname(filePath));
66
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
67
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
68
+ fs.renameSync(tmpPath, filePath);
69
+ }
70
+
71
+ function cleanupRuntimeTmpFiles(runtimeDir, options = {}) {
72
+ if (!fs.existsSync(runtimeDir)) return;
73
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
74
+ const minAgeMs = Number.isFinite(options.tmpCleanupAgeMs)
75
+ ? options.tmpCleanupAgeMs
76
+ : DEFAULT_TMP_CLEANUP_AGE_MS;
77
+ const files = fs.readdirSync(runtimeDir).filter((name) => name.endsWith(".tmp"));
78
+ for (const file of files) {
79
+ const target = path.join(runtimeDir, file);
80
+ try {
81
+ const stat = fs.statSync(target);
82
+ const ageMs = Math.max(0, nowMs - stat.mtimeMs);
83
+ if (ageMs < minAgeMs) continue;
84
+ fs.unlinkSync(target);
85
+ } catch {
86
+ // Ignore temp cleanup failures.
87
+ }
88
+ }
89
+ }
90
+
91
+ function parseDaemonPid(value, fallback = null) {
92
+ if (value === null || value === undefined || value === "") return fallback;
93
+ const parsed = Number.parseInt(value, 10);
94
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
95
+ return parsed;
96
+ }
97
+
98
+ function isPidAlive(pid) {
99
+ if (!Number.isFinite(pid) || pid <= 0) return false;
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ } catch (err) {
104
+ if (err && err.code === "EPERM") return true;
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function isSocketAlive(socketPath) {
110
+ if (!socketPath || typeof socketPath !== "string") return false;
111
+ if (!fs.existsSync(socketPath)) return false;
112
+ try {
113
+ const stat = fs.statSync(socketPath);
114
+ return stat.isSocket();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function normalizeStatus(value, fallback = "running") {
121
+ const raw = String(value || "").trim().toLowerCase();
122
+ if (raw === "running" || raw === "stale" || raw === "stopped") return raw;
123
+ return fallback;
124
+ }
125
+
126
+ function normalizeRuntimeEntry(entry = {}, fallbackProjectRoot = "") {
127
+ const canonicalRoot = canonicalizeForRecord(entry.project_root || fallbackProjectRoot);
128
+ const knownProjectId = String(entry.project_id || "").trim();
129
+ const projectId = /^[a-f0-9]{12}$/.test(knownProjectId)
130
+ ? knownProjectId
131
+ : buildProjectId(canonicalRoot);
132
+ const paths = getUfooPaths(canonicalRoot);
133
+ return {
134
+ version: 1,
135
+ project_id: projectId,
136
+ project_root: canonicalRoot,
137
+ project_name: String(entry.project_name || path.basename(canonicalRoot) || canonicalRoot),
138
+ daemon_pid: parseDaemonPid(entry.daemon_pid, null),
139
+ socket_path: String(entry.socket_path || paths.ufooSock),
140
+ status: normalizeStatus(entry.status, "running"),
141
+ last_seen: normalizeIsoTimestamp(entry.last_seen),
142
+ last_switch_at: entry.last_switch_at ? normalizeIsoTimestamp(entry.last_switch_at) : undefined,
143
+ };
144
+ }
145
+
146
+ function readProjectRuntimeByRoot(projectRoot, options = {}) {
147
+ const filePath = runtimeFilePathByProjectRoot(projectRoot, options);
148
+ if (!fs.existsSync(filePath)) return null;
149
+ const parsed = readJsonFileSafe(filePath);
150
+ if (!parsed || typeof parsed !== "object") return null;
151
+ try {
152
+ return normalizeRuntimeEntry(parsed, projectRoot);
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function upsertProjectRuntime(entry = {}, options = {}) {
159
+ const projectRoot = entry.projectRoot || entry.project_root;
160
+ if (!projectRoot) throw new Error("projectRoot is required");
161
+
162
+ const existing = readProjectRuntimeByRoot(projectRoot, options) || {};
163
+ const normalized = normalizeRuntimeEntry({
164
+ ...existing,
165
+ ...entry,
166
+ project_root: projectRoot,
167
+ project_name: entry.projectName || entry.project_name || existing.project_name,
168
+ daemon_pid: parseDaemonPid(entry.daemonPid ?? entry.daemon_pid, existing.daemon_pid),
169
+ socket_path: entry.socketPath || entry.socket_path || existing.socket_path,
170
+ status: normalizeStatus(entry.status, existing.status || "running"),
171
+ last_seen: normalizeIsoTimestamp(entry.lastSeen || entry.last_seen || new Date().toISOString()),
172
+ last_switch_at: entry.lastSwitchAt || entry.last_switch_at || existing.last_switch_at,
173
+ }, projectRoot);
174
+
175
+ const filePath = runtimeFilePathByProjectId(normalized.project_id, options);
176
+ writeJsonAtomic(filePath, normalized);
177
+ return normalized;
178
+ }
179
+
180
+ function markProjectStopped(projectRoot, options = {}) {
181
+ if (!projectRoot) return null;
182
+ const existing = readProjectRuntimeByRoot(projectRoot, options);
183
+ const paths = getUfooPaths(canonicalizeForRecord(projectRoot));
184
+ return upsertProjectRuntime({
185
+ projectRoot,
186
+ projectName: existing ? existing.project_name : path.basename(projectRoot),
187
+ daemonPid: existing ? existing.daemon_pid : null,
188
+ socketPath: existing ? existing.socket_path : paths.ufooSock,
189
+ status: "stopped",
190
+ lastSeen: new Date().toISOString(),
191
+ lastSwitchAt: existing ? existing.last_switch_at : undefined,
192
+ }, options);
193
+ }
194
+
195
+ function validateProjectRuntime(entry = {}, options = {}) {
196
+ if (!entry || typeof entry !== "object") return null;
197
+ const staleTtlMs = Number.isFinite(options.staleTtlMs) ? options.staleTtlMs : DEFAULT_STALE_TTL_MS;
198
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
199
+ const pidAlive = isPidAlive(parseDaemonPid(entry.daemon_pid, null));
200
+ const socketAlive = isSocketAlive(entry.socket_path);
201
+ const running = pidAlive && socketAlive;
202
+
203
+ const parsedLastSeen = new Date(entry.last_seen);
204
+ const lastSeenMs = Number.isNaN(parsedLastSeen.getTime()) ? null : parsedLastSeen.getTime();
205
+ const ageMs = lastSeenMs === null ? null : Math.max(0, nowMs - lastSeenMs);
206
+
207
+ let status = normalizeStatus(entry.status, "running");
208
+ if (running) {
209
+ status = "running";
210
+ } else if (status === "stopped") {
211
+ // Respect explicit stop state even if pid/socket checks are unavailable.
212
+ status = "stopped";
213
+ } else if (ageMs === null || ageMs > staleTtlMs) {
214
+ status = "stale";
215
+ }
216
+
217
+ return {
218
+ ...entry,
219
+ status,
220
+ validation: {
221
+ pid_alive: pidAlive,
222
+ socket_alive: socketAlive,
223
+ stale_ttl_ms: staleTtlMs,
224
+ age_ms: ageMs,
225
+ validated_at: new Date(nowMs).toISOString(),
226
+ },
227
+ };
228
+ }
229
+
230
+ function listProjectRuntimes(options = {}) {
231
+ const runtimeDir = resolveRuntimeDir(options);
232
+ if (!fs.existsSync(runtimeDir)) return [];
233
+ if (options.cleanupTmp === true) {
234
+ cleanupRuntimeTmpFiles(runtimeDir, options);
235
+ }
236
+
237
+ const files = fs.readdirSync(runtimeDir)
238
+ .filter((name) => name.endsWith(".json"))
239
+ .sort();
240
+
241
+ const rows = [];
242
+ for (const file of files) {
243
+ const parsed = readJsonFileSafe(path.join(runtimeDir, file));
244
+ if (!parsed || typeof parsed !== "object") continue;
245
+ try {
246
+ const normalized = normalizeRuntimeEntry(parsed);
247
+ rows.push(options.validate === false ? normalized : validateProjectRuntime(normalized, options));
248
+ } catch {
249
+ // Ignore malformed runtime entries.
250
+ }
251
+ }
252
+
253
+ rows.sort((a, b) => {
254
+ const aSeen = Date.parse(a.last_seen || 0) || 0;
255
+ const bSeen = Date.parse(b.last_seen || 0) || 0;
256
+ return bSeen - aSeen;
257
+ });
258
+
259
+ return rows;
260
+ }
261
+
262
+ function getCurrentProjectRuntime(projectRoot, options = {}) {
263
+ const runtime = readProjectRuntimeByRoot(projectRoot, options);
264
+ if (!runtime) return null;
265
+ if (options.validate === false) return runtime;
266
+ return validateProjectRuntime(runtime, options);
267
+ }
268
+
269
+ module.exports = {
270
+ DEFAULT_STALE_TTL_MS,
271
+ resolveRuntimeDir,
272
+ runtimeFilePathByProjectId,
273
+ runtimeFilePathByProjectRoot,
274
+ upsertProjectRuntime,
275
+ markProjectStopped,
276
+ listProjectRuntimes,
277
+ getCurrentProjectRuntime,
278
+ validateProjectRuntime,
279
+ };