Skip to content

Commit 1632acf

Browse files
committed
make semble binary upgradable
1 parent 19f0864 commit 1632acf

3 files changed

Lines changed: 226 additions & 13 deletions

File tree

src/services/code-index/semble/__tests__/semble-downloader.spec.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ vi.mock("fs/promises", () => ({
1010
chmod: vi.fn().mockResolvedValue(undefined),
1111
unlink: vi.fn().mockResolvedValue(undefined),
1212
rm: vi.fn().mockResolvedValue(undefined),
13+
readFile: vi.fn(),
14+
writeFile: vi.fn().mockResolvedValue(undefined),
1315
}))
1416

1517
// Mock fs (createWriteStream)
@@ -144,6 +146,8 @@ describe("semble-downloader", () => {
144146

145147
// fs.access resolves => file exists
146148
;(fs.access as any).mockResolvedValue(undefined)
149+
// Version file matches current version
150+
;(fs.readFile as any).mockResolvedValue("v0.3.1")
147151

148152
try {
149153
const result = await downloadSemble("/storage")
@@ -168,6 +172,8 @@ describe("semble-downloader", () => {
168172

169173
// fs.access rejects => file not present
170174
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
175+
// No version file exists
176+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
171177

172178
// Simulate successful download: pipe is called, then "finish" fires
173179
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
@@ -196,6 +202,12 @@ describe("semble-downloader", () => {
196202
expect.any(Object),
197203
)
198204
expect(fs.chmod).toHaveBeenCalledWith(path.join("/storage", "semble", "semble"), 0o755)
205+
// Version file should be written
206+
expect(fs.writeFile).toHaveBeenCalledWith(
207+
path.join("/storage", "semble", ".semble-version"),
208+
"v0.3.1",
209+
"utf-8",
210+
)
199211
// Archive should be cleaned up
200212
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"))
201213
} finally {
@@ -213,6 +225,8 @@ describe("semble-downloader", () => {
213225

214226
// fs.access resolves => file exists
215227
;(fs.access as any).mockResolvedValue(undefined)
228+
// Version file matches
229+
;(fs.readFile as any).mockResolvedValue("v0.3.1")
216230

217231
try {
218232
const result = await downloadSemble("/storage")
@@ -234,6 +248,8 @@ describe("semble-downloader", () => {
234248

235249
// fs.access rejects => file not present
236250
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
251+
// No version file
252+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
237253

238254
// Simulate HTTP error response
239255
mockResponse.statusCode = 404
@@ -257,6 +273,8 @@ describe("semble-downloader", () => {
257273

258274
// fs.access rejects => file not present
259275
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
276+
// No version file
277+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
260278

261279
// First call returns a redirect, second call returns 200
262280
let callCount = 0
@@ -378,6 +396,8 @@ describe("semble-downloader", () => {
378396

379397
// fs.access rejects => file not present, triggering download
380398
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
399+
// No version file
400+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
381401

382402
// Simulate successful download
383403
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
@@ -415,6 +435,8 @@ describe("semble-downloader", () => {
415435

416436
// fs.access rejects => file not present
417437
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
438+
// No version file
439+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
418440

419441
// Simulate successful download
420442
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
@@ -436,4 +458,152 @@ describe("semble-downloader", () => {
436458
}
437459
})
438460
})
461+
462+
describe("downloadSemble - version tracking", () => {
463+
it("should re-download when installed version differs from SEMBLE_VERSION", async () => {
464+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
465+
const originalArch = Object.getOwnPropertyDescriptor(process, "arch")
466+
467+
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
468+
Object.defineProperty(process, "arch", { value: "x64", configurable: true })
469+
470+
// Version file has an old version
471+
;(fs.readFile as any).mockResolvedValue("v0.2.0")
472+
// Binary doesn't matter — version mismatch forces re-download
473+
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
474+
475+
// Simulate successful download
476+
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
477+
if (event === "finish") {
478+
setImmediate(cb)
479+
}
480+
})
481+
482+
try {
483+
const result = await downloadSemble("/storage")
484+
485+
expect(result).toBe(path.join("/storage", "semble", "semble"))
486+
// Should remove old installation
487+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble"), {
488+
recursive: true,
489+
force: true,
490+
})
491+
// Should download the new version
492+
expect(https.get).toHaveBeenCalledWith(expect.stringContaining("v0.3.1"), expect.any(Function))
493+
// Should write the new version file
494+
expect(fs.writeFile).toHaveBeenCalledWith(
495+
path.join("/storage", "semble", ".semble-version"),
496+
"v0.3.1",
497+
"utf-8",
498+
)
499+
} finally {
500+
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
501+
if (originalArch) Object.defineProperty(process, "arch", originalArch)
502+
}
503+
})
504+
505+
it("should skip download when installed version matches SEMBLE_VERSION and binary exists", async () => {
506+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
507+
const originalArch = Object.getOwnPropertyDescriptor(process, "arch")
508+
509+
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
510+
Object.defineProperty(process, "arch", { value: "x64", configurable: true })
511+
512+
// Version matches
513+
;(fs.readFile as any).mockResolvedValue("v0.3.1")
514+
// Binary exists
515+
;(fs.access as any).mockResolvedValue(undefined)
516+
517+
try {
518+
const result = await downloadSemble("/storage")
519+
520+
expect(result).toBe(path.join("/storage", "semble", "semble"))
521+
// Should NOT download
522+
expect(https.get).not.toHaveBeenCalled()
523+
// Should NOT remove the extract dir
524+
expect(fs.rm).not.toHaveBeenCalled()
525+
} finally {
526+
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
527+
if (originalArch) Object.defineProperty(process, "arch", originalArch)
528+
}
529+
})
530+
531+
it("should re-download when version matches but binary is missing", async () => {
532+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
533+
const originalArch = Object.getOwnPropertyDescriptor(process, "arch")
534+
535+
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
536+
Object.defineProperty(process, "arch", { value: "x64", configurable: true })
537+
538+
// Version matches
539+
;(fs.readFile as any).mockResolvedValue("v0.3.1")
540+
// But binary is missing
541+
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
542+
543+
// Simulate successful download
544+
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
545+
if (event === "finish") {
546+
setImmediate(cb)
547+
}
548+
})
549+
550+
try {
551+
const result = await downloadSemble("/storage")
552+
553+
expect(result).toBe(path.join("/storage", "semble", "semble"))
554+
// Should download since binary was missing
555+
expect(https.get).toHaveBeenCalled()
556+
// Should write version file again
557+
expect(fs.writeFile).toHaveBeenCalledWith(
558+
path.join("/storage", "semble", ".semble-version"),
559+
"v0.3.1",
560+
"utf-8",
561+
)
562+
} finally {
563+
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
564+
if (originalArch) Object.defineProperty(process, "arch", originalArch)
565+
}
566+
})
567+
568+
it("should download when no version file exists (first install)", async () => {
569+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")
570+
const originalArch = Object.getOwnPropertyDescriptor(process, "arch")
571+
572+
Object.defineProperty(process, "platform", { value: "linux", configurable: true })
573+
Object.defineProperty(process, "arch", { value: "x64", configurable: true })
574+
575+
// No version file
576+
;(fs.readFile as any).mockRejectedValue(new Error("ENOENT"))
577+
// No binary
578+
;(fs.access as any).mockRejectedValue(new Error("ENOENT"))
579+
580+
// Simulate successful download
581+
mockWriteStream.on.mockImplementation((event: string, cb: () => void) => {
582+
if (event === "finish") {
583+
setImmediate(cb)
584+
}
585+
})
586+
587+
try {
588+
const result = await downloadSemble("/storage")
589+
590+
expect(result).toBe(path.join("/storage", "semble", "semble"))
591+
expect(https.get).toHaveBeenCalled()
592+
// Should NOT try to rm the old dir (no previous version)
593+
expect(fs.rm).not.toHaveBeenCalledWith(
594+
path.join("/storage", "semble"),
595+
expect.objectContaining({ recursive: true }),
596+
)
597+
// Should write version file
598+
expect(fs.writeFile).toHaveBeenCalledWith(
599+
path.join("/storage", "semble", ".semble-version"),
600+
"v0.3.1",
601+
"utf-8",
602+
)
603+
} finally {
604+
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
605+
if (originalArch) Object.defineProperty(process, "arch", originalArch)
606+
}
607+
})
608+
})
439609
})

src/services/code-index/semble/semble-cli.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,8 @@ export class SembleCLI {
104104
const child = spawn(this.semblePath, args, {
105105
shell: false,
106106
timeout: options.timeout,
107-
maxBuffer: 10 * 1024 * 1024,
108107
stdio: ["ignore", "pipe", "pipe"],
109-
} as any)
108+
})
110109

111110
let stdout = ""
112111
let stderr = ""

src/services/code-index/semble/semble-downloader.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const SEMBLE_ARCHIVES: Record<string, { archive: string; binary: string }> = {
2020

2121
const SEMBLE_VERSION = "v0.3.1"
2222
const DOWNLOAD_BASE_URL = `https://github.com/navedmerchant/sembleexec/releases/download/${SEMBLE_VERSION}`
23+
const VERSION_FILE = ".semble-version"
2324

2425
/**
2526
* Returns whether the current platform/arch has a prebuilt semble binary available.
@@ -46,9 +47,35 @@ function getArchiveInfo(platform?: string, arch?: string): { archive: string; bi
4647
return SEMBLE_ARCHIVES[`${p}-${a}`]
4748
}
4849

50+
/**
51+
* Reads the locally installed version from the version metadata file.
52+
* Returns undefined if no version file exists (first install or legacy).
53+
*/
54+
async function getInstalledVersion(storageDir: string): Promise<string | undefined> {
55+
try {
56+
const versionPath = path.join(storageDir, "semble", VERSION_FILE)
57+
const version = (await fs.readFile(versionPath, "utf-8")).trim()
58+
return version || undefined
59+
} catch {
60+
return undefined
61+
}
62+
}
63+
64+
/**
65+
* Writes the version metadata file after a successful download.
66+
*/
67+
async function writeInstalledVersion(storageDir: string, version: string): Promise<void> {
68+
const versionPath = path.join(storageDir, "semble", VERSION_FILE)
69+
await fs.writeFile(versionPath, version, "utf-8")
70+
}
71+
4972
/**
5073
* Downloads and extracts the semble archive for the current platform.
5174
*
75+
* Compares the hardcoded SEMBLE_VERSION against the version stored on disk.
76+
* If they differ (i.e. the version was bumped in source), it re-downloads.
77+
* Otherwise it returns the existing binary path.
78+
*
5279
* The archive is extracted into `storageDir/semble/` and the binary path
5380
* is `storageDir/semble/<binary>`.
5481
*
@@ -67,21 +94,35 @@ export async function downloadSemble(storageDir: string): Promise<string | undef
6794
const extractDir = path.join(storageDir, "semble")
6895
const binaryPath = path.join(extractDir, info.binary)
6996

70-
// Check if already downloaded and extracted
71-
try {
72-
await fs.access(binaryPath)
73-
// Binary exists, make sure it's executable
74-
if (process.platform !== "win32") {
75-
await fs.chmod(binaryPath, 0o755)
97+
// Check if already downloaded at the correct version
98+
const installedVersion = await getInstalledVersion(storageDir)
99+
100+
if (installedVersion === SEMBLE_VERSION) {
101+
try {
102+
await fs.access(binaryPath)
103+
// Binary exists and version matches — nothing to do
104+
if (process.platform !== "win32") {
105+
await fs.chmod(binaryPath, 0o755)
106+
}
107+
return binaryPath
108+
} catch {
109+
// Binary missing despite version file — re-download below
110+
}
111+
}
112+
113+
// Version mismatch — remove old installation before downloading new one
114+
if (installedVersion && installedVersion !== SEMBLE_VERSION) {
115+
console.log(`[SembleDownloader] Version changed from ${installedVersion} to ${SEMBLE_VERSION}, updating...`)
116+
try {
117+
await fs.rm(extractDir, { recursive: true, force: true })
118+
} catch {
119+
// ignore cleanup errors
76120
}
77-
return binaryPath
78-
} catch {
79-
// Not present, download and extract it
80121
}
81122

82123
const url = `${DOWNLOAD_BASE_URL}/${info.archive}`
83124
const archivePath = path.join(storageDir, info.archive)
84-
console.log(`[SembleDownloader] Downloading semble from ${url}`)
125+
console.log(`[SembleDownloader] Downloading semble ${SEMBLE_VERSION} from ${url}`)
85126

86127
try {
87128
await downloadFile(url, archivePath)
@@ -100,14 +141,17 @@ export async function downloadSemble(storageDir: string): Promise<string | undef
100141
await fs.chmod(binaryPath, 0o755)
101142
}
102143

144+
// Record the installed version
145+
await writeInstalledVersion(storageDir, SEMBLE_VERSION)
146+
103147
// Clean up the archive file
104148
try {
105149
await fs.unlink(archivePath)
106150
} catch {
107151
// ignore cleanup errors
108152
}
109153

110-
console.log(`[SembleDownloader] Successfully extracted semble to ${binaryPath}`)
154+
console.log(`[SembleDownloader] Successfully installed semble ${SEMBLE_VERSION} to ${binaryPath}`)
111155
return binaryPath
112156
} catch (error: any) {
113157
// Clean up partial download/extraction

0 commit comments

Comments
 (0)