claudeup 6.2.0 → 6.3.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 (153) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/go-module.test.ts +43 -0
  31. package/src/__tests__/install-command.test.ts +3 -3
  32. package/src/__tests__/install-plan.test.ts +13 -7
  33. package/src/__tests__/manifest.test.ts +16 -5
  34. package/src/__tests__/markdown-renderer.test.ts +0 -1
  35. package/src/__tests__/marketplace-badge.test.ts +111 -90
  36. package/src/__tests__/marketplace-version.test.ts +64 -61
  37. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  38. package/src/__tests__/plugin-requires.test.ts +55 -1
  39. package/src/__tests__/plugin-setup.test.ts +111 -9
  40. package/src/__tests__/profile-command.test.ts +1 -7
  41. package/src/__tests__/profile-materializer.test.ts +31 -8
  42. package/src/__tests__/profile-sync.test.ts +23 -5
  43. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  44. package/src/__tests__/resolve-executable.test.ts +1 -1
  45. package/src/__tests__/resolver.test.ts +38 -13
  46. package/src/__tests__/scope-action.test.ts +52 -50
  47. package/src/__tests__/scope-squares.test.tsx +4 -5
  48. package/src/__tests__/skill-install-files.test.ts +111 -103
  49. package/src/__tests__/symlink-manager.test.ts +27 -10
  50. package/src/__tests__/theme-env.test.ts +281 -0
  51. package/src/__tests__/theme-resolve.test.ts +380 -0
  52. package/src/__tests__/update-apply.test.ts +1 -3
  53. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  54. package/src/__tests__/version-snapshot.test.ts +183 -165
  55. package/src/cli/ansi.ts +23 -15
  56. package/src/cli/doctor.ts +10 -9
  57. package/src/cli/install.ts +3 -3
  58. package/src/cli/profile.ts +5 -4
  59. package/src/cli/prompt.ts +0 -1
  60. package/src/cli/router.ts +69 -2
  61. package/src/cli/update-view.ts +11 -6
  62. package/src/cli/update.ts +9 -4
  63. package/src/cli/upgrade.ts +6 -4
  64. package/src/data/alias-flags.ts +260 -260
  65. package/src/data/gitignore-defaults.ts +20 -20
  66. package/src/data/gitignore-reasons.ts +86 -92
  67. package/src/data/gitignore-templates.ts +14 -14
  68. package/src/data/predefined-profiles.ts +1 -2
  69. package/src/data/skill-repos.ts +43 -35
  70. package/src/main.tsx +29 -6
  71. package/src/opentui.d.ts +1 -3
  72. package/src/prerunner/index.ts +7 -8
  73. package/src/services/alias-settings.ts +20 -23
  74. package/src/services/alias-shell-writer.ts +800 -803
  75. package/src/services/alias-store.ts +77 -77
  76. package/src/services/claude-settings.ts +27 -18
  77. package/src/services/content-drift.ts +14 -4
  78. package/src/services/conventions-manager.ts +692 -617
  79. package/src/services/doctor.ts +464 -403
  80. package/src/services/gitignore-detector.ts +113 -103
  81. package/src/services/gitignore-fixer.ts +204 -189
  82. package/src/services/gitignore-prerun.ts +26 -26
  83. package/src/services/gitignore-resolver.ts +162 -144
  84. package/src/services/gitignore-service.ts +120 -117
  85. package/src/services/install-plan.ts +4 -4
  86. package/src/services/local-marketplace.ts +7 -5
  87. package/src/services/manifest.ts +1 -1
  88. package/src/services/marketplace-catalog-git.ts +5 -2
  89. package/src/services/marketplace-fetcher.ts +241 -235
  90. package/src/services/plugin-manager.ts +17 -20
  91. package/src/services/plugin-mcp-config.ts +2 -2
  92. package/src/services/plugin-requires.ts +16 -8
  93. package/src/services/plugin-setup.ts +32 -15
  94. package/src/services/plugin-version-check.ts +4 -4
  95. package/src/services/profile-materializer.ts +9 -5
  96. package/src/services/profile-sync.ts +12 -4
  97. package/src/services/profiles.ts +3 -3
  98. package/src/services/resolver.ts +10 -6
  99. package/src/services/settings-manager.ts +82 -46
  100. package/src/services/skills-manager.ts +11 -9
  101. package/src/services/symlink-manager.ts +2 -5
  102. package/src/services/toolchain.ts +3 -3
  103. package/src/services/update-cache.ts +1 -1
  104. package/src/services/update-engine.ts +4 -4
  105. package/src/services/update-plan.ts +9 -11
  106. package/src/services/version-snapshot.ts +88 -88
  107. package/src/types/gitignore.ts +28 -28
  108. package/src/ui/App.tsx +36 -36
  109. package/src/ui/adapters/settingsAdapter.ts +2 -2
  110. package/src/ui/components/CategoryHeader.tsx +1 -1
  111. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  112. package/src/ui/components/ScopeIndicator.tsx +3 -1
  113. package/src/ui/components/ScrollableList.tsx +2 -1
  114. package/src/ui/components/StyledText.tsx +1 -1
  115. package/src/ui/components/layout/FooterHints.tsx +5 -3
  116. package/src/ui/components/layout/Panel.tsx +2 -2
  117. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  118. package/src/ui/components/modals/InputModal.tsx +7 -1
  119. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  120. package/src/ui/components/modals/SelectModal.tsx +1 -1
  121. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  122. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  123. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  124. package/src/ui/hooks/index.ts +0 -1
  125. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  126. package/src/ui/hooks/useMismatchModal.ts +4 -10
  127. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  128. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  129. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  130. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  131. package/src/ui/renderers/profileRenderers.tsx +9 -8
  132. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  133. package/src/ui/renderers/skillRenderers.tsx +14 -14
  134. package/src/ui/screens/AliasScreen.tsx +31 -34
  135. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  136. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  137. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  138. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  139. package/src/ui/screens/McpScreen.tsx +10 -10
  140. package/src/ui/screens/PluginsScreen.tsx +17 -21
  141. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  142. package/src/ui/screens/SkillsScreen.tsx +16 -14
  143. package/src/ui/state/AppContext.tsx +4 -8
  144. package/src/ui/state/DimensionsContext.tsx +3 -2
  145. package/src/ui/theme-mode.ts +20 -38
  146. package/src/ui/theme-resolve.ts +148 -0
  147. package/src/utils/clipboard.ts +5 -5
  148. package/src/utils/command-utils.ts +2 -2
  149. package/src/utils/config-dir.ts +1 -3
  150. package/src/utils/file-locking.ts +88 -86
  151. package/src/utils/go-module.ts +36 -0
  152. package/src/ui/hooks/useAsyncData.ts +0 -127
  153. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -13,75 +13,83 @@ import type { PluginInfo } from "../services/plugin-manager.js";
13
13
  */
14
14
 
15
15
  const plugin = (over: Partial<PluginInfo>): PluginInfo =>
16
- ({
17
- id: "x@magus",
18
- name: "x",
19
- version: "1.0.0",
20
- description: "",
21
- marketplace: "magus",
22
- marketplaceDisplay: "Magus",
23
- enabled: true,
24
- ...over,
25
- }) as PluginInfo;
16
+ ({
17
+ id: "x@magus",
18
+ name: "x",
19
+ version: "1.0.0",
20
+ description: "",
21
+ marketplace: "magus",
22
+ marketplaceDisplay: "Magus",
23
+ enabled: true,
24
+ ...over,
25
+ }) as PluginInfo;
26
26
 
27
27
  describe("isEnabledButNotInstalled", () => {
28
- test("enabled in project scope with no installed version is flagged", () => {
29
- // The real case: terminal/code-analysis are in this project's enabledPlugins
30
- // but have no registry entry for it and none at user scope.
31
- expect(
32
- isEnabledButNotInstalled(
33
- plugin({ projectScope: { enabled: true }, installedVersion: undefined }),
34
- ),
35
- ).toBe(true);
36
- });
28
+ test("enabled in project scope with no installed version is flagged", () => {
29
+ // The real case: terminal/code-analysis are in this project's enabledPlugins
30
+ // but have no registry entry for it and none at user scope.
31
+ expect(
32
+ isEnabledButNotInstalled(
33
+ plugin({
34
+ projectScope: { enabled: true },
35
+ installedVersion: undefined,
36
+ }),
37
+ ),
38
+ ).toBe(true);
39
+ });
37
40
 
38
- test("a healthy install is not flagged", () => {
39
- expect(
40
- isEnabledButNotInstalled(
41
- plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
42
- ),
43
- ).toBe(false);
44
- });
41
+ test("a healthy install is not flagged", () => {
42
+ expect(
43
+ isEnabledButNotInstalled(
44
+ plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
45
+ ),
46
+ ).toBe(false);
47
+ });
45
48
 
46
- test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
47
- // Anthropic's official plugins record exactly this. Treating it as missing
48
- // would mislabel every one of them as broken.
49
- expect(
50
- isEnabledButNotInstalled(
51
- plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
52
- ),
53
- ).toBe(false);
54
- });
49
+ test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
50
+ // Anthropic's official plugins record exactly this. Treating it as missing
51
+ // would mislabel every one of them as broken.
52
+ expect(
53
+ isEnabledButNotInstalled(
54
+ plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
55
+ ),
56
+ ).toBe(false);
57
+ });
55
58
 
56
- test("not enabled anywhere is not flagged — it is simply available", () => {
57
- expect(isEnabledButNotInstalled(plugin({ installedVersion: undefined }))).toBe(false);
58
- });
59
+ test("not enabled anywhere is not flagged — it is simply available", () => {
60
+ expect(
61
+ isEnabledButNotInstalled(plugin({ installedVersion: undefined })),
62
+ ).toBe(false);
63
+ });
59
64
 
60
- test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
61
- expect(
62
- isEnabledButNotInstalled(
63
- plugin({
64
- isOrphaned: true,
65
- projectScope: { enabled: true },
66
- installedVersion: undefined,
67
- }),
68
- ),
69
- ).toBe(false);
70
- });
65
+ test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
66
+ expect(
67
+ isEnabledButNotInstalled(
68
+ plugin({
69
+ isOrphaned: true,
70
+ projectScope: { enabled: true },
71
+ installedVersion: undefined,
72
+ }),
73
+ ),
74
+ ).toBe(false);
75
+ });
71
76
 
72
- test("any enabled scope counts, not just project", () => {
73
- for (const scope of ["userScope", "projectScope", "localScope"] as const) {
74
- expect(
75
- isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
76
- ).toBe(true);
77
- }
78
- });
77
+ test("any enabled scope counts, not just project", () => {
78
+ for (const scope of ["userScope", "projectScope", "localScope"] as const) {
79
+ expect(
80
+ isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
81
+ ).toBe(true);
82
+ }
83
+ });
79
84
 
80
- test("a scope present but disabled does not count as enabled", () => {
81
- expect(
82
- isEnabledButNotInstalled(
83
- plugin({ projectScope: { enabled: false }, installedVersion: undefined }),
84
- ),
85
- ).toBe(false);
86
- });
85
+ test("a scope present but disabled does not count as enabled", () => {
86
+ expect(
87
+ isEnabledButNotInstalled(
88
+ plugin({
89
+ projectScope: { enabled: false },
90
+ installedVersion: undefined,
91
+ }),
92
+ ),
93
+ ).toBe(false);
94
+ });
87
95
  });
@@ -8,201 +8,201 @@
8
8
  * - Lock timeout handling
9
9
  */
10
10
 
11
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
12
- import { mkdtemp, rm } from 'node:fs/promises';
13
- import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
11
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
12
+ import { mkdtemp, rm } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
15
  import {
16
- withFileLock,
17
- isFileLocked,
18
- forceRemoveLock,
19
- getLockInfo,
20
- } from '../utils/file-locking.js';
21
-
22
- describe('file-locking', () => {
23
- let testDir: string;
24
- let testFile: string;
25
-
26
- beforeEach(async () => {
27
- // Create temporary directory for tests
28
- testDir = await mkdtemp(join(tmpdir(), 'claudeup-test-'));
29
- testFile = join(testDir, 'test-file.json');
30
- });
31
-
32
- afterEach(async () => {
33
- // Clean up test directory
34
- await rm(testDir, { recursive: true, force: true });
35
- });
36
-
37
- describe('withFileLock', () => {
38
- it('should execute operation with lock held - TEST-095', async () => {
39
- let operationExecuted = false;
40
-
41
- await withFileLock(testFile, async () => {
42
- operationExecuted = true;
43
- expect(isFileLocked(testFile)).toBe(true);
44
- });
45
-
46
- expect(operationExecuted).toBe(true);
47
- });
48
-
49
- it('should release lock after operation completes - TEST-095', async () => {
50
- await withFileLock(testFile, async () => {
51
- // Operation
52
- });
53
-
54
- expect(isFileLocked(testFile)).toBe(false);
55
- });
56
-
57
- it('should release lock even if operation throws', async () => {
58
- await expect(
59
- withFileLock(testFile, async () => {
60
- throw new Error('Operation failed');
61
- }),
62
- ).rejects.toThrow('Operation failed');
63
-
64
- expect(isFileLocked(testFile)).toBe(false);
65
- });
66
-
67
- it('should return operation result', async () => {
68
- const result = await withFileLock(testFile, async () => {
69
- return 'success';
70
- });
71
-
72
- expect(result).toBe('success');
73
- });
74
-
75
- it('should block concurrent access - TEST-095', async () => {
76
- const executionOrder: number[] = [];
77
-
78
- // First operation holds lock for 100ms
79
- const promise1 = withFileLock(testFile, async () => {
80
- executionOrder.push(1);
81
- await new Promise((resolve) => setTimeout(resolve, 100));
82
- executionOrder.push(2);
83
- });
84
-
85
- // Wait a bit to ensure first lock is acquired
86
- await new Promise((resolve) => setTimeout(resolve, 10));
87
-
88
- // Second operation should wait for lock
89
- const promise2 = withFileLock(testFile, async () => {
90
- executionOrder.push(3);
91
- });
92
-
93
- await Promise.all([promise1, promise2]);
94
-
95
- // Second operation should only execute after first completes
96
- expect(executionOrder).toEqual([1, 2, 3]);
97
- });
98
-
99
- it('should timeout if lock cannot be acquired', async () => {
100
- // Skip this test in CI as it takes 10+ seconds
101
- // The timeout behavior is tested in integration tests
102
- });
103
-
104
- it('should handle multiple sequential operations', async () => {
105
- for (let i = 0; i < 5; i++) {
106
- await withFileLock(testFile, async () => {
107
- expect(isFileLocked(testFile)).toBe(true);
108
- });
109
- }
110
-
111
- expect(isFileLocked(testFile)).toBe(false);
112
- });
113
- });
114
-
115
- describe('isFileLocked', () => {
116
- it('should return false for unlocked files', () => {
117
- expect(isFileLocked(testFile)).toBe(false);
118
- });
119
-
120
- it('should return true while lock is held', async () => {
121
- await withFileLock(testFile, async () => {
122
- expect(isFileLocked(testFile)).toBe(true);
123
- });
124
- });
125
-
126
- it('should return false after lock is released', async () => {
127
- await withFileLock(testFile, async () => {});
128
- expect(isFileLocked(testFile)).toBe(false);
129
- });
130
- });
131
-
132
- describe('forceRemoveLock', () => {
133
- it('should remove lock file', async () => {
134
- // Create lock by starting operation but not finishing
135
- await withFileLock(testFile, async () => {
136
- // Lock is held here
137
- expect(isFileLocked(testFile)).toBe(true);
138
- });
139
-
140
- // Lock should be released
141
- expect(isFileLocked(testFile)).toBe(false);
142
-
143
- // Force remove should not throw on non-existent lock
144
- await forceRemoveLock(testFile);
145
- expect(isFileLocked(testFile)).toBe(false);
146
- });
147
-
148
- it('should not throw if lock file does not exist', async () => {
149
- await forceRemoveLock(testFile);
150
- expect(isFileLocked(testFile)).toBe(false);
151
- });
152
- });
153
-
154
- describe('getLockInfo', () => {
155
- it('should return null for unlocked files', async () => {
156
- const info = await getLockInfo(testFile);
157
- expect(info).toBeNull();
158
- });
159
-
160
- it('should return lock information while locked', async () => {
161
- await withFileLock(testFile, async () => {
162
- const info = await getLockInfo(testFile);
163
- expect(info).not.toBeNull();
164
- expect(info?.pid).toBe(String(process.pid));
165
- expect(info?.timestamp).toBeDefined();
166
- });
167
- });
168
-
169
- it('should return null after lock is released', async () => {
170
- await withFileLock(testFile, async () => {});
171
- const info = await getLockInfo(testFile);
172
- expect(info).toBeNull();
173
- });
174
- });
175
-
176
- describe('edge cases', () => {
177
- it('should handle nested lock attempts on different files', async () => {
178
- const file1 = join(testDir, 'file1.json');
179
- const file2 = join(testDir, 'file2.json');
180
-
181
- await withFileLock(file1, async () => {
182
- await withFileLock(file2, async () => {
183
- expect(isFileLocked(file1)).toBe(true);
184
- expect(isFileLocked(file2)).toBe(true);
185
- });
186
- });
187
-
188
- expect(isFileLocked(file1)).toBe(false);
189
- expect(isFileLocked(file2)).toBe(false);
190
- });
191
-
192
- it('should prevent nested lock attempts on same file', async () => {
193
- // Skip this test in CI as it takes 10+ seconds to timeout
194
- // The nested lock prevention is tested via other tests
195
- });
196
-
197
- it('should handle rapid lock/unlock cycles', async () => {
198
- const iterations = 10;
199
- for (let i = 0; i < iterations; i++) {
200
- await withFileLock(testFile, async () => {
201
- await new Promise((resolve) => setTimeout(resolve, 10));
202
- });
203
- }
204
-
205
- expect(isFileLocked(testFile)).toBe(false);
206
- });
207
- });
16
+ forceRemoveLock,
17
+ getLockInfo,
18
+ isFileLocked,
19
+ withFileLock,
20
+ } from "../utils/file-locking.js";
21
+
22
+ describe("file-locking", () => {
23
+ let testDir: string;
24
+ let testFile: string;
25
+
26
+ beforeEach(async () => {
27
+ // Create temporary directory for tests
28
+ testDir = await mkdtemp(join(tmpdir(), "claudeup-test-"));
29
+ testFile = join(testDir, "test-file.json");
30
+ });
31
+
32
+ afterEach(async () => {
33
+ // Clean up test directory
34
+ await rm(testDir, { recursive: true, force: true });
35
+ });
36
+
37
+ describe("withFileLock", () => {
38
+ it("should execute operation with lock held - TEST-095", async () => {
39
+ let operationExecuted = false;
40
+
41
+ await withFileLock(testFile, async () => {
42
+ operationExecuted = true;
43
+ expect(isFileLocked(testFile)).toBe(true);
44
+ });
45
+
46
+ expect(operationExecuted).toBe(true);
47
+ });
48
+
49
+ it("should release lock after operation completes - TEST-095", async () => {
50
+ await withFileLock(testFile, async () => {
51
+ // Operation
52
+ });
53
+
54
+ expect(isFileLocked(testFile)).toBe(false);
55
+ });
56
+
57
+ it("should release lock even if operation throws", async () => {
58
+ await expect(
59
+ withFileLock(testFile, async () => {
60
+ throw new Error("Operation failed");
61
+ }),
62
+ ).rejects.toThrow("Operation failed");
63
+
64
+ expect(isFileLocked(testFile)).toBe(false);
65
+ });
66
+
67
+ it("should return operation result", async () => {
68
+ const result = await withFileLock(testFile, async () => {
69
+ return "success";
70
+ });
71
+
72
+ expect(result).toBe("success");
73
+ });
74
+
75
+ it("should block concurrent access - TEST-095", async () => {
76
+ const executionOrder: number[] = [];
77
+
78
+ // First operation holds lock for 100ms
79
+ const promise1 = withFileLock(testFile, async () => {
80
+ executionOrder.push(1);
81
+ await new Promise((resolve) => setTimeout(resolve, 100));
82
+ executionOrder.push(2);
83
+ });
84
+
85
+ // Wait a bit to ensure first lock is acquired
86
+ await new Promise((resolve) => setTimeout(resolve, 10));
87
+
88
+ // Second operation should wait for lock
89
+ const promise2 = withFileLock(testFile, async () => {
90
+ executionOrder.push(3);
91
+ });
92
+
93
+ await Promise.all([promise1, promise2]);
94
+
95
+ // Second operation should only execute after first completes
96
+ expect(executionOrder).toEqual([1, 2, 3]);
97
+ });
98
+
99
+ it("should timeout if lock cannot be acquired", async () => {
100
+ // Skip this test in CI as it takes 10+ seconds
101
+ // The timeout behavior is tested in integration tests
102
+ });
103
+
104
+ it("should handle multiple sequential operations", async () => {
105
+ for (let i = 0; i < 5; i++) {
106
+ await withFileLock(testFile, async () => {
107
+ expect(isFileLocked(testFile)).toBe(true);
108
+ });
109
+ }
110
+
111
+ expect(isFileLocked(testFile)).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("isFileLocked", () => {
116
+ it("should return false for unlocked files", () => {
117
+ expect(isFileLocked(testFile)).toBe(false);
118
+ });
119
+
120
+ it("should return true while lock is held", async () => {
121
+ await withFileLock(testFile, async () => {
122
+ expect(isFileLocked(testFile)).toBe(true);
123
+ });
124
+ });
125
+
126
+ it("should return false after lock is released", async () => {
127
+ await withFileLock(testFile, async () => {});
128
+ expect(isFileLocked(testFile)).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("forceRemoveLock", () => {
133
+ it("should remove lock file", async () => {
134
+ // Create lock by starting operation but not finishing
135
+ await withFileLock(testFile, async () => {
136
+ // Lock is held here
137
+ expect(isFileLocked(testFile)).toBe(true);
138
+ });
139
+
140
+ // Lock should be released
141
+ expect(isFileLocked(testFile)).toBe(false);
142
+
143
+ // Force remove should not throw on non-existent lock
144
+ await forceRemoveLock(testFile);
145
+ expect(isFileLocked(testFile)).toBe(false);
146
+ });
147
+
148
+ it("should not throw if lock file does not exist", async () => {
149
+ await forceRemoveLock(testFile);
150
+ expect(isFileLocked(testFile)).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe("getLockInfo", () => {
155
+ it("should return null for unlocked files", async () => {
156
+ const info = await getLockInfo(testFile);
157
+ expect(info).toBeNull();
158
+ });
159
+
160
+ it("should return lock information while locked", async () => {
161
+ await withFileLock(testFile, async () => {
162
+ const info = await getLockInfo(testFile);
163
+ expect(info).not.toBeNull();
164
+ expect(info?.pid).toBe(String(process.pid));
165
+ expect(info?.timestamp).toBeDefined();
166
+ });
167
+ });
168
+
169
+ it("should return null after lock is released", async () => {
170
+ await withFileLock(testFile, async () => {});
171
+ const info = await getLockInfo(testFile);
172
+ expect(info).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe("edge cases", () => {
177
+ it("should handle nested lock attempts on different files", async () => {
178
+ const file1 = join(testDir, "file1.json");
179
+ const file2 = join(testDir, "file2.json");
180
+
181
+ await withFileLock(file1, async () => {
182
+ await withFileLock(file2, async () => {
183
+ expect(isFileLocked(file1)).toBe(true);
184
+ expect(isFileLocked(file2)).toBe(true);
185
+ });
186
+ });
187
+
188
+ expect(isFileLocked(file1)).toBe(false);
189
+ expect(isFileLocked(file2)).toBe(false);
190
+ });
191
+
192
+ it("should prevent nested lock attempts on same file", async () => {
193
+ // Skip this test in CI as it takes 10+ seconds to timeout
194
+ // The nested lock prevention is tested via other tests
195
+ });
196
+
197
+ it("should handle rapid lock/unlock cycles", async () => {
198
+ const iterations = 10;
199
+ for (let i = 0; i < iterations; i++) {
200
+ await withFileLock(testFile, async () => {
201
+ await new Promise((resolve) => setTimeout(resolve, 10));
202
+ });
203
+ }
204
+
205
+ expect(isFileLocked(testFile)).toBe(false);
206
+ });
207
+ });
208
208
  });