@stati/core 1.0.0 → 1.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.
Files changed (41) hide show
  1. package/README.md +217 -0
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +24 -2
  4. package/dist/core/build.d.ts +9 -2
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +200 -46
  7. package/dist/core/dev.d.ts +21 -0
  8. package/dist/core/dev.d.ts.map +1 -0
  9. package/dist/core/dev.js +371 -0
  10. package/dist/core/invalidate.d.ts +67 -1
  11. package/dist/core/invalidate.d.ts.map +1 -1
  12. package/dist/core/invalidate.js +321 -4
  13. package/dist/core/isg/build-lock.d.ts +116 -0
  14. package/dist/core/isg/build-lock.d.ts.map +1 -0
  15. package/dist/core/isg/build-lock.js +243 -0
  16. package/dist/core/isg/builder.d.ts +51 -0
  17. package/dist/core/isg/builder.d.ts.map +1 -0
  18. package/dist/core/isg/builder.js +321 -0
  19. package/dist/core/isg/deps.d.ts +63 -0
  20. package/dist/core/isg/deps.d.ts.map +1 -0
  21. package/dist/core/isg/deps.js +332 -0
  22. package/dist/core/isg/hash.d.ts +48 -0
  23. package/dist/core/isg/hash.d.ts.map +1 -0
  24. package/dist/core/isg/hash.js +82 -0
  25. package/dist/core/isg/manifest.d.ts +47 -0
  26. package/dist/core/isg/manifest.d.ts.map +1 -0
  27. package/dist/core/isg/manifest.js +233 -0
  28. package/dist/core/isg/ttl.d.ts +101 -0
  29. package/dist/core/isg/ttl.d.ts.map +1 -0
  30. package/dist/core/isg/ttl.js +222 -0
  31. package/dist/core/isg/validation.d.ts +71 -0
  32. package/dist/core/isg/validation.d.ts.map +1 -0
  33. package/dist/core/isg/validation.js +226 -0
  34. package/dist/core/templates.d.ts.map +1 -1
  35. package/dist/core/templates.js +23 -5
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/types.d.ts +172 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +7 -3
@@ -1,7 +1,324 @@
1
+ import { join } from 'path';
2
+ import { loadCacheManifest, saveCacheManifest } from './isg/manifest.js';
3
+ /**
4
+ * Parses an invalidation query string into individual query terms.
5
+ * Supports space-separated values and quoted strings.
6
+ *
7
+ * @param query - The query string to parse
8
+ * @returns Array of parsed query terms
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * parseInvalidationQuery('tag:blog path:/posts') // ['tag:blog', 'path:/posts']
13
+ * parseInvalidationQuery('"tag:my tag" path:"/my path"') // ['tag:my tag', 'path:/my path']
14
+ * ```
15
+ */
16
+ export function parseInvalidationQuery(query) {
17
+ const terms = [];
18
+ let current = '';
19
+ let inQuotes = false;
20
+ let quoteChar = '';
21
+ for (let i = 0; i < query.length; i++) {
22
+ const char = query[i];
23
+ if (char === '"' || char === "'") {
24
+ if (!inQuotes) {
25
+ inQuotes = true;
26
+ quoteChar = char;
27
+ }
28
+ else if (char === quoteChar) {
29
+ inQuotes = false;
30
+ quoteChar = '';
31
+ }
32
+ else {
33
+ current += char;
34
+ }
35
+ }
36
+ else if (char === ' ' && !inQuotes) {
37
+ if (current.trim()) {
38
+ terms.push(current.trim());
39
+ current = '';
40
+ }
41
+ }
42
+ else {
43
+ current += char;
44
+ }
45
+ }
46
+ if (current.trim()) {
47
+ terms.push(current.trim());
48
+ }
49
+ return terms;
50
+ }
51
+ /**
52
+ * Checks if a cache entry matches a specific invalidation term.
53
+ *
54
+ * @param entry - Cache entry to check
55
+ * @param path - The page path for this entry
56
+ * @param term - Invalidation term to match against
57
+ * @returns True if the entry matches the term
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * matchesInvalidationTerm(entry, '/blog/post-1', 'tag:blog') // true if entry has 'blog' tag
62
+ * matchesInvalidationTerm(entry, '/blog/post-1', 'path:/blog') // true (path prefix match)
63
+ * ```
64
+ */
65
+ export function matchesInvalidationTerm(entry, path, term) {
66
+ // Parse term into type and value
67
+ if (term.includes(':')) {
68
+ const [type, value] = term.split(':', 2);
69
+ // Ensure both type and value exist
70
+ if (!type || !value) {
71
+ return false;
72
+ }
73
+ switch (type.toLowerCase()) {
74
+ case 'tag':
75
+ return entry.tags.includes(value);
76
+ case 'path':
77
+ // Support both exact path match and prefix match
78
+ return path === value || path.startsWith(value);
79
+ case 'glob':
80
+ // Simple glob pattern matching for paths
81
+ return matchesGlob(path, value);
82
+ case 'age':
83
+ // Time-based invalidation: age:3months, age:1week, age:30days
84
+ return matchesAge(entry, value);
85
+ default:
86
+ console.warn(`Unknown invalidation term type: ${type}`);
87
+ return false;
88
+ }
89
+ }
90
+ else {
91
+ // Plain term - search in tags and path
92
+ return entry.tags.some((tag) => tag.includes(term)) || path.includes(term);
93
+ }
94
+ }
95
+ /**
96
+ * Simple glob pattern matching for paths.
97
+ * Supports * and ** wildcards.
98
+ *
99
+ * @param path - Path to test
100
+ * @param pattern - Glob pattern
101
+ * @returns True if path matches pattern
102
+ */
103
+ function matchesGlob(path, pattern) {
104
+ try {
105
+ // Convert glob pattern to regex by processing character by character
106
+ // This avoids the magic string replacement issue
107
+ const regex = globToRegex(pattern);
108
+ return regex.test(path);
109
+ }
110
+ catch {
111
+ console.warn(`Invalid glob pattern: ${pattern}`);
112
+ return false;
113
+ }
114
+ }
115
+ /**
116
+ * Converts a glob pattern to a regular expression.
117
+ * Processes the pattern character by character to avoid placeholder conflicts.
118
+ *
119
+ * @param pattern - Glob pattern to convert
120
+ * @returns Regular expression that matches the glob pattern
121
+ */
122
+ function globToRegex(pattern) {
123
+ let regexStr = '^';
124
+ let i = 0;
125
+ while (i < pattern.length) {
126
+ const char = pattern[i];
127
+ switch (char) {
128
+ case '*':
129
+ if (i + 1 < pattern.length && pattern[i + 1] === '*') {
130
+ // Handle ** (matches any path including subdirectories)
131
+ if (i + 2 < pattern.length && pattern[i + 2] === '/') {
132
+ // **/ pattern - matches zero or more directories
133
+ regexStr += '(?:.*/)?';
134
+ i += 3;
135
+ }
136
+ else if (i + 2 === pattern.length) {
137
+ // ** at end - matches everything
138
+ regexStr += '.*';
139
+ i += 2;
140
+ }
141
+ else {
142
+ // ** not followed by / or end - treat as single *
143
+ regexStr += '[^/]*';
144
+ i += 1;
145
+ }
146
+ }
147
+ else {
148
+ // Single * matches any characters except path separator
149
+ regexStr += '[^/]*';
150
+ i += 1;
151
+ }
152
+ break;
153
+ case '?':
154
+ // ? matches any single character except path separator
155
+ regexStr += '[^/]';
156
+ i += 1;
157
+ break;
158
+ case '[': {
159
+ // Handle character classes - find the closing bracket
160
+ let closeIndex = i + 1;
161
+ while (closeIndex < pattern.length && pattern[closeIndex] !== ']') {
162
+ closeIndex++;
163
+ }
164
+ if (closeIndex >= pattern.length) {
165
+ // No closing bracket found - this creates an invalid regex
166
+ // Just add the character and let the regex constructor throw an error
167
+ regexStr += char;
168
+ i += 1;
169
+ }
170
+ else {
171
+ // Valid character class - copy it as-is
172
+ regexStr += pattern.slice(i, closeIndex + 1);
173
+ i = closeIndex + 1;
174
+ }
175
+ break;
176
+ }
177
+ case '.':
178
+ case '+':
179
+ case '^':
180
+ case '$':
181
+ case '(':
182
+ case ')':
183
+ case ']':
184
+ case '{':
185
+ case '}':
186
+ case '|':
187
+ case '\\':
188
+ // Escape regex special characters
189
+ regexStr += '\\' + char;
190
+ i += 1;
191
+ break;
192
+ default:
193
+ // Regular character
194
+ regexStr += char;
195
+ i += 1;
196
+ break;
197
+ }
198
+ }
199
+ regexStr += '$';
200
+ return new RegExp(regexStr);
201
+ }
202
+ /**
203
+ * Checks if a cache entry matches an age-based invalidation term.
204
+ * Supports various time units: days, weeks, months, years.
205
+ *
206
+ * @param entry - Cache entry to check
207
+ * @param ageValue - Age specification (e.g., "3months", "1week", "30days")
208
+ * @returns True if the entry is younger than the specified age
209
+ *
210
+ * @example
211
+ * ```typescript
212
+ * matchesAge(entry, "3months") // true if rendered within the last 3 months
213
+ * matchesAge(entry, "1week") // true if rendered within the last 1 week
214
+ * ```
215
+ */
216
+ function matchesAge(entry, ageValue) {
217
+ const now = new Date();
218
+ const renderedAt = new Date(entry.renderedAt);
219
+ // Parse age value (e.g., "3months", "1week", "30days")
220
+ const match = ageValue.match(/^(\d+)(days?|weeks?|months?|years?)$/i);
221
+ if (!match || !match[1] || !match[2]) {
222
+ console.warn(`Invalid age format: ${ageValue}. Use format like "3months", "1week", "30days"`);
223
+ return false;
224
+ }
225
+ const numStr = match[1];
226
+ const unit = match[2];
227
+ const num = parseInt(numStr, 10);
228
+ if (isNaN(num) || num <= 0) {
229
+ console.warn(`Invalid age number: ${numStr}`);
230
+ return false;
231
+ }
232
+ // Calculate cutoff date
233
+ const cutoffDate = new Date(now);
234
+ switch (unit.toLowerCase()) {
235
+ case 'day':
236
+ case 'days':
237
+ cutoffDate.setDate(cutoffDate.getDate() - num);
238
+ break;
239
+ case 'week':
240
+ case 'weeks':
241
+ cutoffDate.setDate(cutoffDate.getDate() - num * 7);
242
+ break;
243
+ case 'month':
244
+ case 'months':
245
+ cutoffDate.setMonth(cutoffDate.getMonth() - num);
246
+ break;
247
+ case 'year':
248
+ case 'years':
249
+ cutoffDate.setFullYear(cutoffDate.getFullYear() - num);
250
+ break;
251
+ default:
252
+ console.warn(`Unknown time unit: ${unit}`);
253
+ return false;
254
+ }
255
+ // Entry matches if it was rendered after the cutoff date (i.e., younger than specified age)
256
+ return renderedAt > cutoffDate;
257
+ }
258
+ /**
259
+ * Invalidates cache entries based on a query string.
260
+ * Supports tag-based, path-based, pattern-based, and time-based invalidation.
261
+ *
262
+ * @param query - Invalidation query string, or undefined to clear all cache
263
+ * @returns Promise resolving to invalidation result
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * // Invalidate all pages with 'blog' tag
268
+ * await invalidate('tag:blog');
269
+ *
270
+ * // Invalidate specific path
271
+ * await invalidate('path:/about');
272
+ *
273
+ * // Invalidate content younger than 3 months
274
+ * await invalidate('age:3months');
275
+ *
276
+ * // Invalidate multiple criteria
277
+ * await invalidate('tag:blog age:1week');
278
+ *
279
+ * // Clear entire cache
280
+ * await invalidate();
281
+ * ```
282
+ */
1
283
  export async function invalidate(query) {
2
- // TODO This will be implemented in Milestone 4 (ISG)
3
- console.log('Invalidate functionality will be available in a future release');
4
- if (query) {
5
- console.log(`Query: ${query}`);
284
+ const cacheDir = join(process.cwd(), '.stati');
285
+ // Load existing cache manifest
286
+ let cacheManifest = await loadCacheManifest(cacheDir);
287
+ if (!cacheManifest) {
288
+ // No cache to invalidate
289
+ return {
290
+ invalidatedCount: 0,
291
+ invalidatedPaths: [],
292
+ clearedAll: false,
293
+ };
294
+ }
295
+ const invalidatedPaths = [];
296
+ if (!query || query.trim() === '') {
297
+ // Clear entire cache
298
+ invalidatedPaths.push(...Object.keys(cacheManifest.entries));
299
+ cacheManifest.entries = {};
300
+ await saveCacheManifest(cacheDir, cacheManifest);
301
+ return {
302
+ invalidatedCount: invalidatedPaths.length,
303
+ invalidatedPaths,
304
+ clearedAll: true,
305
+ };
306
+ }
307
+ // Parse query terms
308
+ const terms = parseInvalidationQuery(query.trim());
309
+ // Find entries that match any of the terms
310
+ for (const [path, entry] of Object.entries(cacheManifest.entries)) {
311
+ const shouldInvalidate = terms.some((term) => matchesInvalidationTerm(entry, path, term));
312
+ if (shouldInvalidate) {
313
+ delete cacheManifest.entries[path];
314
+ invalidatedPaths.push(path);
315
+ }
6
316
  }
317
+ // Save updated cache manifest
318
+ await saveCacheManifest(cacheDir, cacheManifest);
319
+ return {
320
+ invalidatedCount: invalidatedPaths.length,
321
+ invalidatedPaths,
322
+ clearedAll: false,
323
+ };
7
324
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Build lock information stored in the lock file.
3
+ */
4
+ interface BuildLock {
5
+ pid: number;
6
+ timestamp: string;
7
+ hostname?: string;
8
+ }
9
+ /**
10
+ * Manages build process locking to prevent concurrent Stati builds from corrupting cache.
11
+ * Uses a simple file-based locking mechanism with process ID tracking.
12
+ */
13
+ export declare class BuildLockManager {
14
+ private lockPath;
15
+ private isLocked;
16
+ constructor(cacheDir: string);
17
+ /**
18
+ * Attempts to acquire a build lock.
19
+ * Throws an error if another build process is already running.
20
+ *
21
+ * @param options - Lock acquisition options
22
+ * @param options.force - Force acquire lock even if another process holds it
23
+ * @param options.timeout - Maximum time to wait for lock in milliseconds
24
+ * @throws {Error} When lock cannot be acquired
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * const lockManager = new BuildLockManager('.stati');
29
+ * try {
30
+ * await lockManager.acquireLock();
31
+ * // Proceed with build
32
+ * } finally {
33
+ * await lockManager.releaseLock();
34
+ * }
35
+ * ```
36
+ */
37
+ acquireLock(options?: {
38
+ force?: boolean;
39
+ timeout?: number;
40
+ }): Promise<void>;
41
+ /**
42
+ * Releases the build lock if this process owns it.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * await lockManager.releaseLock();
47
+ * ```
48
+ */
49
+ releaseLock(): Promise<void>;
50
+ /**
51
+ * Checks if a build lock is currently held by any process.
52
+ *
53
+ * @returns True if a lock exists and the owning process is running
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * if (await lockManager.isLocked()) {
58
+ * console.log('Another build is in progress');
59
+ * }
60
+ * ```
61
+ */
62
+ isLockHeld(): Promise<boolean>;
63
+ /**
64
+ * Gets information about the current lock holder.
65
+ *
66
+ * @returns Lock information or null if no lock exists
67
+ */
68
+ getLockInfo(): Promise<BuildLock | null>;
69
+ /**
70
+ * Force removes the lock file without checking ownership.
71
+ * Should only be used in error recovery scenarios.
72
+ */
73
+ private forceRemoveLock;
74
+ /**
75
+ * Creates a new lock file with current process information.
76
+ */
77
+ private createLockFile;
78
+ /**
79
+ * Reads and parses the lock file.
80
+ */
81
+ private readLockFile;
82
+ /**
83
+ * Checks if a process with the given PID is currently running.
84
+ */
85
+ private isProcessRunning;
86
+ /**
87
+ * Gets the hostname for lock identification.
88
+ */
89
+ private getHostname;
90
+ /**
91
+ * Simple sleep utility for polling delays.
92
+ */
93
+ private sleep;
94
+ }
95
+ /**
96
+ * Convenience function to safely execute a build with automatic lock management.
97
+ *
98
+ * @param cacheDir - Path to the cache directory
99
+ * @param buildFn - Function to execute while holding the lock
100
+ * @param options - Lock options
101
+ * @returns Result of the build function
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * const result = await withBuildLock('.stati', async () => {
106
+ * // Your build logic here
107
+ * return await performBuild();
108
+ * });
109
+ * ```
110
+ */
111
+ export declare function withBuildLock<T>(cacheDir: string, buildFn: () => Promise<T>, options?: {
112
+ force?: boolean;
113
+ timeout?: number;
114
+ }): Promise<T>;
115
+ export {};
116
+ //# sourceMappingURL=build-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-lock.d.ts","sourceRoot":"","sources":["../../../src/core/isg/build-lock.ts"],"names":[],"mappings":"AAYA;;GAEG;AACH,UAAU,SAAS;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;gBAEb,QAAQ,EAAE,MAAM;IAI5B;;;;;;;;;;;;;;;;;;;OAmBG;IACG,WAAW,CAAC,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAoDrF;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAqBlC;;;;;;;;;;;OAWG;IACG,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAiBpC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAY9C;;;OAGG;YACW,eAAe;IAQ7B;;OAEG;YACW,cAAc;IAW5B;;OAEG;YACW,YAAY;IAS1B;;OAEG;YACW,gBAAgB;IAY9B;;OAEG;IACH,OAAO,CAAC,WAAW;IAQnB;;OAEG;IACH,OAAO,CAAC,KAAK;CAGd;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAClD,OAAO,CAAC,CAAC,CAAC,CASZ"}
@@ -0,0 +1,243 @@
1
+ import fse from 'fs-extra';
2
+ const { writeFile, readFile, pathExists, remove } = fse;
3
+ import { join } from 'path';
4
+ import { hostname } from 'os';
5
+ /**
6
+ * Manages build process locking to prevent concurrent Stati builds from corrupting cache.
7
+ * Uses a simple file-based locking mechanism with process ID tracking.
8
+ */
9
+ export class BuildLockManager {
10
+ lockPath;
11
+ isLocked = false;
12
+ constructor(cacheDir) {
13
+ this.lockPath = join(cacheDir, '.build-lock');
14
+ }
15
+ /**
16
+ * Attempts to acquire a build lock.
17
+ * Throws an error if another build process is already running.
18
+ *
19
+ * @param options - Lock acquisition options
20
+ * @param options.force - Force acquire lock even if another process holds it
21
+ * @param options.timeout - Maximum time to wait for lock in milliseconds
22
+ * @throws {Error} When lock cannot be acquired
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const lockManager = new BuildLockManager('.stati');
27
+ * try {
28
+ * await lockManager.acquireLock();
29
+ * // Proceed with build
30
+ * } finally {
31
+ * await lockManager.releaseLock();
32
+ * }
33
+ * ```
34
+ */
35
+ async acquireLock(options = {}) {
36
+ const { force = false, timeout = 30000 } = options;
37
+ const startTime = Date.now();
38
+ while (Date.now() - startTime < timeout) {
39
+ try {
40
+ // Check if lock file exists
41
+ if (await pathExists(this.lockPath)) {
42
+ const existingLock = await this.readLockFile();
43
+ if (existingLock && !force) {
44
+ // Check if the process is still running
45
+ if (await this.isProcessRunning(existingLock.pid)) {
46
+ // Wait a bit and try again
47
+ await this.sleep(1000);
48
+ continue;
49
+ }
50
+ else {
51
+ // Process is dead, remove stale lock
52
+ console.warn(`Removing stale build lock (PID ${existingLock.pid} no longer running)`);
53
+ await this.forceRemoveLock();
54
+ }
55
+ }
56
+ else if (force) {
57
+ console.warn('Force acquiring build lock, removing existing lock');
58
+ await this.forceRemoveLock();
59
+ }
60
+ }
61
+ // Try to create the lock
62
+ await this.createLockFile();
63
+ this.isLocked = true;
64
+ return;
65
+ }
66
+ catch (error) {
67
+ const nodeError = error;
68
+ if (nodeError.code === 'EEXIST') {
69
+ // Another process created the lock between our check and creation
70
+ await this.sleep(1000);
71
+ continue;
72
+ }
73
+ throw new Error(`Failed to acquire build lock: ${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
76
+ throw new Error(`Build lock acquisition timed out after ${timeout}ms. ` +
77
+ `Another Stati build process may be running. Use --force to override.`);
78
+ }
79
+ /**
80
+ * Releases the build lock if this process owns it.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * await lockManager.releaseLock();
85
+ * ```
86
+ */
87
+ async releaseLock() {
88
+ if (!this.isLocked) {
89
+ return;
90
+ }
91
+ try {
92
+ // Verify we still own the lock before removing it
93
+ const currentLock = await this.readLockFile();
94
+ if (currentLock && currentLock.pid === process.pid) {
95
+ await remove(this.lockPath);
96
+ }
97
+ }
98
+ catch (error) {
99
+ // Don't throw on release errors, just warn
100
+ console.warn(`Warning: Failed to release build lock: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+ finally {
103
+ this.isLocked = false;
104
+ }
105
+ }
106
+ /**
107
+ * Checks if a build lock is currently held by any process.
108
+ *
109
+ * @returns True if a lock exists and the owning process is running
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * if (await lockManager.isLocked()) {
114
+ * console.log('Another build is in progress');
115
+ * }
116
+ * ```
117
+ */
118
+ async isLockHeld() {
119
+ try {
120
+ if (!(await pathExists(this.lockPath))) {
121
+ return false;
122
+ }
123
+ const lock = await this.readLockFile();
124
+ if (!lock) {
125
+ return false;
126
+ }
127
+ return await this.isProcessRunning(lock.pid);
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
133
+ /**
134
+ * Gets information about the current lock holder.
135
+ *
136
+ * @returns Lock information or null if no lock exists
137
+ */
138
+ async getLockInfo() {
139
+ try {
140
+ if (!(await pathExists(this.lockPath))) {
141
+ return null;
142
+ }
143
+ return await this.readLockFile();
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ /**
150
+ * Force removes the lock file without checking ownership.
151
+ * Should only be used in error recovery scenarios.
152
+ */
153
+ async forceRemoveLock() {
154
+ try {
155
+ await remove(this.lockPath);
156
+ }
157
+ catch {
158
+ // Ignore errors when force removing
159
+ }
160
+ }
161
+ /**
162
+ * Creates a new lock file with current process information.
163
+ */
164
+ async createLockFile() {
165
+ const lockInfo = {
166
+ pid: process.pid,
167
+ timestamp: new Date().toISOString(),
168
+ hostname: this.getHostname(),
169
+ };
170
+ // Use 'wx' flag to create file exclusively (fails if exists)
171
+ await writeFile(this.lockPath, JSON.stringify(lockInfo, null, 2), { flag: 'wx' });
172
+ }
173
+ /**
174
+ * Reads and parses the lock file.
175
+ */
176
+ async readLockFile() {
177
+ try {
178
+ const content = await readFile(this.lockPath, 'utf-8');
179
+ return JSON.parse(content);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Checks if a process with the given PID is currently running.
187
+ */
188
+ async isProcessRunning(pid) {
189
+ try {
190
+ // On Unix systems, sending signal 0 checks if process exists without affecting it
191
+ process.kill(pid, 0);
192
+ return true;
193
+ }
194
+ catch (error) {
195
+ const nodeError = error;
196
+ // ESRCH means process doesn't exist
197
+ return nodeError.code !== 'ESRCH';
198
+ }
199
+ }
200
+ /**
201
+ * Gets the hostname for lock identification.
202
+ */
203
+ getHostname() {
204
+ try {
205
+ return hostname();
206
+ }
207
+ catch {
208
+ return 'unknown';
209
+ }
210
+ }
211
+ /**
212
+ * Simple sleep utility for polling delays.
213
+ */
214
+ sleep(ms) {
215
+ return new Promise((resolve) => global.setTimeout(resolve, ms));
216
+ }
217
+ }
218
+ /**
219
+ * Convenience function to safely execute a build with automatic lock management.
220
+ *
221
+ * @param cacheDir - Path to the cache directory
222
+ * @param buildFn - Function to execute while holding the lock
223
+ * @param options - Lock options
224
+ * @returns Result of the build function
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * const result = await withBuildLock('.stati', async () => {
229
+ * // Your build logic here
230
+ * return await performBuild();
231
+ * });
232
+ * ```
233
+ */
234
+ export async function withBuildLock(cacheDir, buildFn, options = {}) {
235
+ const lockManager = new BuildLockManager(cacheDir);
236
+ try {
237
+ await lockManager.acquireLock(options);
238
+ return await buildFn();
239
+ }
240
+ finally {
241
+ await lockManager.releaseLock();
242
+ }
243
+ }