opencode-repos 0.1.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.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "opencode-repos",
3
+ "version": "0.1.0",
4
+ "description": "Repository cache, registry, and cross-codebase intelligence for OpenCode agents",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "repos",
11
+ "repository",
12
+ "cache",
13
+ "codebase"
14
+ ],
15
+ "author": "Liam Vinberg",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/liamjv1/opencode-repos"
20
+ },
21
+ "peerDependencies": {
22
+ "@opencode-ai/plugin": "*"
23
+ },
24
+ "dependencies": {
25
+ "glob": "^10.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@opencode-ai/plugin": "^1.1.25",
29
+ "@types/bun": "^1.2.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }
@@ -0,0 +1,141 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test"
2
+ import { rm, mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { tmpdir } from "node:os"
5
+ import {
6
+ parseRepoSpec,
7
+ buildGitUrl,
8
+ cloneRepo,
9
+ updateRepo,
10
+ getRepoInfo,
11
+ } from "../git"
12
+
13
+ describe("parseRepoSpec", () => {
14
+ test("parses owner/repo without branch", () => {
15
+ const result = parseRepoSpec("vercel/next.js")
16
+ expect(result).toEqual({
17
+ owner: "vercel",
18
+ repo: "next.js",
19
+ branch: null,
20
+ })
21
+ })
22
+
23
+ test("parses owner/repo@branch", () => {
24
+ const result = parseRepoSpec("vercel/next.js@canary")
25
+ expect(result).toEqual({
26
+ owner: "vercel",
27
+ repo: "next.js",
28
+ branch: "canary",
29
+ })
30
+ })
31
+
32
+ test("handles branch with special characters", () => {
33
+ const result = parseRepoSpec("owner/repo@feature/my-branch")
34
+ expect(result).toEqual({
35
+ owner: "owner",
36
+ repo: "repo",
37
+ branch: "feature/my-branch",
38
+ })
39
+ })
40
+
41
+ test("throws on missing slash", () => {
42
+ expect(() => parseRepoSpec("invalid")).toThrow(
43
+ 'Invalid repo spec: must be in format "owner/repo"'
44
+ )
45
+ })
46
+
47
+ test("throws on empty owner", () => {
48
+ expect(() => parseRepoSpec("/repo")).toThrow(
49
+ "Invalid repo spec: owner and repo cannot be empty"
50
+ )
51
+ })
52
+
53
+ test("throws on empty repo", () => {
54
+ expect(() => parseRepoSpec("owner/")).toThrow(
55
+ "Invalid repo spec: owner and repo cannot be empty"
56
+ )
57
+ })
58
+
59
+ test("throws on empty branch after @", () => {
60
+ expect(() => parseRepoSpec("owner/repo@")).toThrow(
61
+ "Invalid repo spec: branch cannot be empty after @"
62
+ )
63
+ })
64
+
65
+ test("throws on repo with extra slash", () => {
66
+ expect(() => parseRepoSpec("owner/repo/extra")).toThrow(
67
+ 'Invalid repo spec: repo name cannot contain "/"'
68
+ )
69
+ })
70
+ })
71
+
72
+ describe("buildGitUrl", () => {
73
+ test("builds SSH URL correctly", () => {
74
+ const url = buildGitUrl("vercel", "next.js")
75
+ expect(url).toBe("git@github.com:vercel/next.js.git")
76
+ })
77
+
78
+ test("handles various owner/repo names", () => {
79
+ expect(buildGitUrl("facebook", "react")).toBe(
80
+ "git@github.com:facebook/react.git"
81
+ )
82
+ expect(buildGitUrl("microsoft", "TypeScript")).toBe(
83
+ "git@github.com:microsoft/TypeScript.git"
84
+ )
85
+ })
86
+ })
87
+
88
+ describe("git operations (integration)", () => {
89
+ const testDir = join(tmpdir(), `opencode-repos-test-${Date.now()}`)
90
+ const repoPath = join(testDir, "test-repo")
91
+
92
+ beforeAll(async () => {
93
+ await mkdir(testDir, { recursive: true })
94
+ })
95
+
96
+ afterAll(async () => {
97
+ await rm(testDir, { recursive: true, force: true })
98
+ })
99
+
100
+ test("cloneRepo clones a public repository", async () => {
101
+ await cloneRepo(
102
+ "https://github.com/octocat/Hello-World.git",
103
+ repoPath,
104
+ { branch: "master" }
105
+ )
106
+
107
+ const exists = await Bun.file(join(repoPath, ".git/config")).exists()
108
+ expect(exists).toBe(true)
109
+ })
110
+
111
+ test("getRepoInfo returns correct information", async () => {
112
+ const info = await getRepoInfo(repoPath)
113
+
114
+ expect(info.remote).toBe("https://github.com/octocat/Hello-World.git")
115
+ expect(info.branch).toBe("master")
116
+ expect(info.commit).toMatch(/^[a-f0-9]{40}$/)
117
+ })
118
+
119
+ test("updateRepo fetches and resets", async () => {
120
+ await updateRepo(repoPath, "master")
121
+ const infoAfter = await getRepoInfo(repoPath)
122
+
123
+ expect(infoAfter.branch).toBe("master")
124
+ expect(infoAfter.commit).toMatch(/^[a-f0-9]{40}$/)
125
+ })
126
+
127
+ test("cloneRepo cleans up on failure", async () => {
128
+ const badPath = join(testDir, "bad-clone")
129
+
130
+ try {
131
+ await cloneRepo(
132
+ "https://github.com/nonexistent-user-12345/nonexistent-repo-67890.git",
133
+ badPath
134
+ )
135
+ expect(true).toBe(false)
136
+ } catch {
137
+ const exists = await Bun.file(badPath).exists()
138
+ expect(exists).toBe(false)
139
+ }
140
+ })
141
+ })
@@ -0,0 +1,249 @@
1
+ import { test, expect, beforeEach, afterEach, describe } from "bun:test"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { mkdir, rm } from "node:fs/promises"
5
+ import { loadManifest, saveManifest, withManifestLock, type Manifest } from "../manifest"
6
+
7
+ const originalCacheDir = join(homedir(), ".cache", "opencode-repos")
8
+
9
+ describe("loadManifest", () => {
10
+ beforeEach(async () => {
11
+ await rm(originalCacheDir, { recursive: true, force: true })
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await rm(originalCacheDir, { recursive: true, force: true })
16
+ })
17
+
18
+ test("creates empty manifest if file does not exist", async () => {
19
+ const manifest = await loadManifest()
20
+
21
+ expect(manifest).toEqual({
22
+ version: 1,
23
+ repos: {},
24
+ localIndex: {},
25
+ })
26
+ })
27
+
28
+ test("returns existing manifest when file exists", async () => {
29
+ await mkdir(originalCacheDir, { recursive: true })
30
+
31
+ const existingManifest: Manifest = {
32
+ version: 1,
33
+ repos: {
34
+ "owner/repo@main": {
35
+ type: "cached",
36
+ path: "/some/path",
37
+ lastAccessed: "2024-01-01T00:00:00.000Z",
38
+ defaultBranch: "main",
39
+ shallow: true,
40
+ },
41
+ },
42
+ localIndex: {
43
+ "https://github.com/owner/repo.git": "/local/path",
44
+ },
45
+ }
46
+
47
+ await Bun.write(
48
+ join(originalCacheDir, "manifest.json"),
49
+ JSON.stringify(existingManifest, null, 2)
50
+ )
51
+
52
+ const manifest = await loadManifest()
53
+
54
+ expect(manifest).toEqual(existingManifest)
55
+ })
56
+
57
+ test("handles corrupted JSON gracefully", async () => {
58
+ await mkdir(originalCacheDir, { recursive: true })
59
+ await Bun.write(join(originalCacheDir, "manifest.json"), "{ invalid json }")
60
+
61
+ const manifest = await loadManifest()
62
+
63
+ expect(manifest).toEqual({
64
+ version: 1,
65
+ repos: {},
66
+ localIndex: {},
67
+ })
68
+ })
69
+ })
70
+
71
+ describe("saveManifest", () => {
72
+ beforeEach(async () => {
73
+ await rm(originalCacheDir, { recursive: true, force: true })
74
+ })
75
+
76
+ afterEach(async () => {
77
+ await rm(originalCacheDir, { recursive: true, force: true })
78
+ })
79
+
80
+ test("creates directory if it does not exist", async () => {
81
+ const manifest: Manifest = {
82
+ version: 1,
83
+ repos: {},
84
+ localIndex: {},
85
+ }
86
+
87
+ await saveManifest(manifest)
88
+
89
+ const file = Bun.file(join(originalCacheDir, "manifest.json"))
90
+ expect(await file.exists()).toBe(true)
91
+ })
92
+
93
+ test("writes manifest atomically using tmp file", async () => {
94
+ const manifest: Manifest = {
95
+ version: 1,
96
+ repos: {
97
+ "test/repo@main": {
98
+ type: "cached",
99
+ path: "/test/path",
100
+ lastAccessed: "2024-01-01T00:00:00.000Z",
101
+ defaultBranch: "main",
102
+ shallow: false,
103
+ },
104
+ },
105
+ localIndex: {},
106
+ }
107
+
108
+ await saveManifest(manifest)
109
+
110
+ const savedContent = await Bun.file(join(originalCacheDir, "manifest.json")).text()
111
+ const savedManifest = JSON.parse(savedContent)
112
+
113
+ expect(savedManifest).toEqual(manifest)
114
+
115
+ const tmpFile = Bun.file(join(originalCacheDir, "manifest.json.tmp"))
116
+ expect(await tmpFile.exists()).toBe(false)
117
+ })
118
+
119
+ test("overwrites existing manifest", async () => {
120
+ const manifest1: Manifest = {
121
+ version: 1,
122
+ repos: { "first/repo@main": { type: "cached", path: "/first", lastAccessed: "2024-01-01T00:00:00.000Z", defaultBranch: "main", shallow: true } },
123
+ localIndex: {},
124
+ }
125
+
126
+ const manifest2: Manifest = {
127
+ version: 1,
128
+ repos: { "second/repo@main": { type: "local", path: "/second", lastAccessed: "2024-02-01T00:00:00.000Z", defaultBranch: "main", shallow: false } },
129
+ localIndex: { "url": "/path" },
130
+ }
131
+
132
+ await saveManifest(manifest1)
133
+ await saveManifest(manifest2)
134
+
135
+ const savedContent = await Bun.file(join(originalCacheDir, "manifest.json")).text()
136
+ const savedManifest = JSON.parse(savedContent)
137
+
138
+ expect(savedManifest).toEqual(manifest2)
139
+ })
140
+ })
141
+
142
+ describe("withManifestLock", () => {
143
+ beforeEach(async () => {
144
+ await rm(originalCacheDir, { recursive: true, force: true })
145
+ })
146
+
147
+ afterEach(async () => {
148
+ await rm(originalCacheDir, { recursive: true, force: true })
149
+ })
150
+
151
+ test("executes callback and returns result", async () => {
152
+ const result = await withManifestLock(async () => {
153
+ return "test-result"
154
+ })
155
+
156
+ expect(result).toBe("test-result")
157
+ })
158
+
159
+ test("releases lock after callback completes", async () => {
160
+ await withManifestLock(async () => {
161
+ return "done"
162
+ })
163
+
164
+ const lockFile = Bun.file(join(originalCacheDir, "manifest.lock"))
165
+ expect(await lockFile.exists()).toBe(false)
166
+ })
167
+
168
+ test("releases lock even if callback throws", async () => {
169
+ try {
170
+ await withManifestLock(async () => {
171
+ throw new Error("test error")
172
+ })
173
+ } catch {
174
+ // expected
175
+ }
176
+
177
+ const lockFile = Bun.file(join(originalCacheDir, "manifest.lock"))
178
+ expect(await lockFile.exists()).toBe(false)
179
+ })
180
+
181
+ test("prevents concurrent access", async () => {
182
+ const results: number[] = []
183
+
184
+ const task1 = withManifestLock(async () => {
185
+ results.push(1)
186
+ await Bun.sleep(50)
187
+ results.push(2)
188
+ return "task1"
189
+ })
190
+
191
+ await Bun.sleep(10)
192
+
193
+ const task2 = withManifestLock(async () => {
194
+ results.push(3)
195
+ return "task2"
196
+ })
197
+
198
+ await Promise.all([task1, task2])
199
+
200
+ expect(results).toEqual([1, 2, 3])
201
+ })
202
+
203
+ test("handles stale lock by removing it", async () => {
204
+ await mkdir(originalCacheDir, { recursive: true })
205
+
206
+ const staleTime = Date.now() - (6 * 60 * 1000)
207
+ await Bun.write(join(originalCacheDir, "manifest.lock"), String(staleTime))
208
+
209
+ const { utimes } = await import("node:fs/promises")
210
+ const staleDate = new Date(staleTime)
211
+ await utimes(join(originalCacheDir, "manifest.lock"), staleDate, staleDate)
212
+
213
+ const result = await withManifestLock(async () => {
214
+ return "acquired-after-stale"
215
+ })
216
+
217
+ expect(result).toBe("acquired-after-stale")
218
+ })
219
+ })
220
+
221
+ describe("integration", () => {
222
+ beforeEach(async () => {
223
+ await rm(originalCacheDir, { recursive: true, force: true })
224
+ })
225
+
226
+ afterEach(async () => {
227
+ await rm(originalCacheDir, { recursive: true, force: true })
228
+ })
229
+
230
+ test("load, modify, and save manifest with lock", async () => {
231
+ await withManifestLock(async () => {
232
+ const manifest = await loadManifest()
233
+
234
+ manifest.repos["new/repo@main"] = {
235
+ type: "cached",
236
+ path: "/new/path",
237
+ lastAccessed: new Date().toISOString(),
238
+ defaultBranch: "main",
239
+ shallow: true,
240
+ }
241
+
242
+ await saveManifest(manifest)
243
+ })
244
+
245
+ const loaded = await loadManifest()
246
+ expect(loaded.repos["new/repo@main"]).toBeDefined()
247
+ expect(loaded.repos["new/repo@main"].type).toBe("cached")
248
+ })
249
+ })
@@ -0,0 +1,5 @@
1
+ import { test, expect } from "bun:test";
2
+
3
+ test("project setup is valid", () => {
4
+ expect(true).toBe(true);
5
+ });
@@ -0,0 +1,52 @@
1
+ import type { AgentConfig } from "@opencode-ai/sdk"
2
+
3
+ export function createRepoExplorerAgent(): AgentConfig {
4
+ return {
5
+ description:
6
+ "Specialized agent for exploring external codebases. Use when you need to understand another project's architecture, APIs, patterns, or implementation details to integrate with it or learn from it.",
7
+ mode: "subagent",
8
+ temperature: 0.1,
9
+ permission: {
10
+ edit: "deny",
11
+ },
12
+ prompt: `You are a codebase exploration specialist. Your job is to deeply understand external codebases and report your findings clearly.
13
+
14
+ ## Your Capabilities
15
+ - Read and analyze source code across any programming language
16
+ - Search for patterns and implementations using grep, glob, and AST tools
17
+ - Understand project structure and architecture
18
+ - Identify APIs, interfaces, and integration points
19
+ - Trace code paths and data flows
20
+ - Explain complex implementations in simple terms
21
+
22
+ ## Your Approach
23
+ 1. **Start high-level**: Begin with README, package.json, main entry points to understand the project's purpose
24
+ 2. **Map the structure**: Identify key directories and their purposes (src/, lib/, tests/, etc.)
25
+ 3. **Trace relevant paths**: Follow the code paths relevant to the specific question
26
+ 4. **Be specific**: Always cite file paths and line numbers
27
+ 5. **Show examples**: Include relevant code snippets to illustrate your findings
28
+ 6. **Explain interactions**: Describe how components, modules, and APIs interact
29
+
30
+ ## Output Format
31
+ - **Be specific**: Always cite file paths (with line numbers if relevant)
32
+ - **Include code snippets**: Show relevant portions of code with context
33
+ - **Explain architecture**: Describe how components interact
34
+ - **Note patterns**: Highlight any patterns, conventions, or best practices used
35
+ - **Provide examples**: Give concrete examples of how to use discovered APIs
36
+
37
+ ## Important Constraints
38
+ - You are **READ-ONLY**: You cannot modify, create, or delete any files
39
+ - You cannot spawn tasks or sub-agents
40
+ - Your job is to explore and report, not to modify
41
+ - Focus on understanding and explaining, not implementing
42
+
43
+ ## Example Questions You Might Answer
44
+ - "How does authentication work in this codebase?"
45
+ - "What's the API for creating a new user?"
46
+ - "How does the routing system work?"
47
+ - "Find all places where database transactions are used"
48
+ - "What patterns does this project use for error handling?"
49
+
50
+ Remember: Be thorough but concise. Focus on what's relevant to the question while providing enough context for understanding.`,
51
+ }
52
+ }
package/src/git.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { $ } from "bun"
2
+ import { rm } from "node:fs/promises"
3
+
4
+ export interface RepoSpec {
5
+ owner: string
6
+ repo: string
7
+ branch: string | null
8
+ }
9
+
10
+ export interface RepoInfo {
11
+ remote: string
12
+ branch: string
13
+ commit: string
14
+ }
15
+
16
+ export interface CloneOptions {
17
+ branch?: string
18
+ }
19
+
20
+ export function parseRepoSpec(spec: string): RepoSpec {
21
+ const atIndex = spec.indexOf("@")
22
+ let repoPath: string
23
+ let branch: string | null = null
24
+
25
+ if (atIndex !== -1) {
26
+ repoPath = spec.slice(0, atIndex)
27
+ branch = spec.slice(atIndex + 1)
28
+ if (!branch) {
29
+ throw new Error(`Invalid repo spec: branch cannot be empty after @`)
30
+ }
31
+ } else {
32
+ repoPath = spec
33
+ }
34
+
35
+ const slashIndex = repoPath.indexOf("/")
36
+ if (slashIndex === -1) {
37
+ throw new Error(`Invalid repo spec: must be in format "owner/repo" or "owner/repo@branch"`)
38
+ }
39
+
40
+ const owner = repoPath.slice(0, slashIndex)
41
+ const repo = repoPath.slice(slashIndex + 1)
42
+
43
+ if (!owner || !repo) {
44
+ throw new Error(`Invalid repo spec: owner and repo cannot be empty`)
45
+ }
46
+
47
+ if (repo.includes("/")) {
48
+ throw new Error(`Invalid repo spec: repo name cannot contain "/"`)
49
+ }
50
+
51
+ return { owner, repo, branch }
52
+ }
53
+
54
+ export function buildGitUrl(owner: string, repo: string): string {
55
+ return `git@github.com:${owner}/${repo}.git`
56
+ }
57
+
58
+ export async function cloneRepo(
59
+ url: string,
60
+ destPath: string,
61
+ options: CloneOptions = {}
62
+ ): Promise<void> {
63
+ const branch = options.branch || "main"
64
+
65
+ try {
66
+ await $`git clone --depth=1 --single-branch --branch ${branch} --config core.hooksPath=/dev/null ${url} ${destPath}`.quiet()
67
+ } catch (error) {
68
+ try {
69
+ await rm(destPath, { recursive: true, force: true })
70
+ } catch {}
71
+ throw error
72
+ }
73
+ }
74
+
75
+ export async function updateRepo(path: string, branch: string = "main"): Promise<void> {
76
+ await $`git -C ${path} fetch origin ${branch} --depth=1`.quiet()
77
+ await $`git -C ${path} reset --hard origin/${branch}`.quiet()
78
+ }
79
+
80
+ export async function getRepoInfo(path: string): Promise<RepoInfo> {
81
+ const remote = await $`git -C ${path} remote get-url origin`.text()
82
+ const branch = await $`git -C ${path} branch --show-current`.text()
83
+ const commit = await $`git -C ${path} rev-parse HEAD`.text()
84
+
85
+ return {
86
+ remote: remote.trim(),
87
+ branch: branch.trim(),
88
+ commit: commit.trim(),
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import { mkdir, rename, unlink, stat } from "node:fs/promises"
4
+
5
+ export interface RepoEntry {
6
+ type: "cached" | "local"
7
+ path: string
8
+ clonedAt?: string
9
+ lastAccessed: string
10
+ lastUpdated?: string
11
+ sizeBytes?: number
12
+ defaultBranch: string
13
+ shallow: boolean
14
+ }
15
+
16
+ export interface Manifest {
17
+ version: 1
18
+ repos: Record<string, RepoEntry>
19
+ localIndex: Record<string, string>
20
+ }
21
+
22
+ export interface Config {
23
+ localSearchPaths: string[]
24
+ }
25
+
26
+ const CACHE_DIR = join(homedir(), ".cache", "opencode-repos")
27
+ const MANIFEST_PATH = join(CACHE_DIR, "manifest.json")
28
+ const MANIFEST_TMP_PATH = join(CACHE_DIR, "manifest.json.tmp")
29
+ const LOCK_PATH = join(CACHE_DIR, "manifest.lock")
30
+ const LOCK_STALE_MS = 5 * 60 * 1000
31
+
32
+ function createEmptyManifest(): Manifest {
33
+ return {
34
+ version: 1,
35
+ repos: {},
36
+ localIndex: {},
37
+ }
38
+ }
39
+
40
+ export async function loadManifest(): Promise<Manifest> {
41
+ const file = Bun.file(MANIFEST_PATH)
42
+ const exists = await file.exists()
43
+
44
+ if (!exists) {
45
+ return createEmptyManifest()
46
+ }
47
+
48
+ try {
49
+ const content = await file.text()
50
+ const parsed = JSON.parse(content) as Manifest
51
+ return parsed
52
+ } catch {
53
+ console.warn("[opencode-repos] Manifest corrupted, returning empty manifest")
54
+ return createEmptyManifest()
55
+ }
56
+ }
57
+
58
+ export async function saveManifest(manifest: Manifest): Promise<void> {
59
+ await mkdir(CACHE_DIR, { recursive: true })
60
+ await Bun.write(MANIFEST_TMP_PATH, JSON.stringify(manifest, null, 2))
61
+ await rename(MANIFEST_TMP_PATH, MANIFEST_PATH)
62
+ }
63
+
64
+ async function isLockStale(): Promise<boolean> {
65
+ try {
66
+ const lockStat = await stat(LOCK_PATH)
67
+ const age = Date.now() - lockStat.mtimeMs
68
+ return age > LOCK_STALE_MS
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
74
+ async function acquireLock(): Promise<void> {
75
+ const maxAttempts = 50
76
+ const retryDelayMs = 100
77
+
78
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
79
+ const lockFile = Bun.file(LOCK_PATH)
80
+ const exists = await lockFile.exists()
81
+
82
+ if (exists) {
83
+ if (await isLockStale()) {
84
+ await unlink(LOCK_PATH).catch(() => {})
85
+ } else {
86
+ await Bun.sleep(retryDelayMs)
87
+ continue
88
+ }
89
+ }
90
+
91
+ try {
92
+ await mkdir(CACHE_DIR, { recursive: true })
93
+ await Bun.write(LOCK_PATH, String(Date.now()))
94
+ return
95
+ } catch {
96
+ await Bun.sleep(retryDelayMs)
97
+ }
98
+ }
99
+
100
+ throw new Error("Failed to acquire manifest lock after maximum attempts")
101
+ }
102
+
103
+ async function releaseLock(): Promise<void> {
104
+ await unlink(LOCK_PATH).catch(() => {})
105
+ }
106
+
107
+ export async function withManifestLock<T>(
108
+ callback: () => Promise<T>
109
+ ): Promise<T> {
110
+ await acquireLock()
111
+ try {
112
+ return await callback()
113
+ } finally {
114
+ await releaseLock()
115
+ }
116
+ }