nodejs: download libsimplex into the user cache

This commit is contained in:
shum
2026-09-25 12:51:14 +00:00
parent 67d00585c3
commit a5ceeac000
6 changed files with 589 additions and 0 deletions
@@ -3,6 +3,9 @@
"version": "7.1.0-beta.4",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"simplex-chat": "dist/cli.js"
},
"files": [
"src",
"cpp",
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import {parseArgs} from "util"
import type {Backend} from "./core"
import {resolveLibsDir} from "./libs"
const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]"
const EXIT_INSTALL_FAILED = 1
const EXIT_USAGE = 2
export function parseInstallArgs(argv: string[]): Backend {
const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}})
if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install")
if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`)
return backend
}
export async function main(argv: string[]): Promise<number> {
if (argv.includes("-h") || argv.includes("--help")) {
console.log(USAGE)
return 0
}
let backend: Backend
try {
backend = parseInstallArgs(argv)
} catch (e) {
console.error(`${(e as Error).message}\n${USAGE}`)
return EXIT_USAGE
}
try {
const dir = await resolveLibsDir(backend)
console.log(`libsimplex installed at: ${dir}`)
return 0
} catch (e) {
console.error(`install failed: ${(e as Error).message}`)
return EXIT_INSTALL_FAILED
}
}
if (require.main === module) {
main(process.argv.slice(2)).then(code => { process.exitCode = code })
}
+2
View File
@@ -1,6 +1,8 @@
import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
import * as simplex from "./simplex"
export type Backend = "sqlite" | "postgres"
/**
* Initialize chat controller
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
+139
View File
@@ -0,0 +1,139 @@
import * as fs from "fs"
import * as http from "http"
import * as https from "https"
import * as os from "os"
import * as path from "path"
import {pipeline} from "stream/promises"
import extract = require("extract-zip")
import type {Backend} from "./core"
export const LIBS_VERSION = "7.1.0-beta.4"
const GITHUB_REPO = "simplex-chat/simplex-chat-libs"
const REQUEST_TIMEOUT_MS = 60_000
export const MAX_REDIRECTS = 5
const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = {
linux: {name: "linux", lib: "libsimplex.so"},
darwin: {name: "macos", lib: "libsimplex.dylib"},
win32: {name: "windows", lib: "libsimplex.dll"},
}
const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"}
const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"]
function unsupported(platform: string, arch: string): Error {
return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
}
export function platformTag(platform: string, arch: string): string {
const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}`
if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch)
return tag
}
function libName(platform: string = process.platform, arch: string = process.arch): string {
const lib = PLATFORMS[platform]?.lib
if (!lib) throw unsupported(platform, arch)
return lib
}
export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string {
if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat")
if (platform === "win32") {
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set")
return path.join(env.LOCALAPPDATA, "simplex-chat")
}
return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat")
}
export function libsUrl(backend: Backend, tag: string): string {
const suffix = backend === "postgres" ? "-postgres" : ""
return `https://github.com/${GITHUB_REPO}/releases/download/v${LIBS_VERSION}/simplex-chat-libs-${tag}${suffix}.zip`
}
export function libPath(dir: string): string {
return path.join(dir, libName())
}
export async function resolveLibsDir(
backend: Backend,
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform,
arch: string = process.arch
): Promise<string> {
const lib = libName(platform, arch)
if (env.SIMPLEX_LIBS_DIR) {
const dir = path.resolve(env.SIMPLEX_LIBS_DIR)
if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`)
return dir
}
const tag = platformTag(platform, arch)
if (backend === "postgres" && tag !== "linux-x86_64") {
throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`)
}
const target = path.resolve(cacheRoot(platform, env), `v${LIBS_VERSION}`, backend)
if (!fs.existsSync(path.join(target, lib))) await installLibs(libsUrl(backend, tag), target, lib)
return target
}
export async function installLibs(url: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
const parent = path.dirname(target)
await fs.promises.mkdir(parent, {recursive: true})
const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-"))
try {
console.error(`Downloading libsimplex from ${url} ...`)
const zipPath = path.join(tmp, "libs.zip")
await download(url, zipPath, timeoutMs)
await extract(zipPath, {dir: tmp})
const extracted = path.join(tmp, "libs")
if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
try {
await fs.promises.rename(extracted, target)
} catch (e) {
// Another process installed the same version first; its files are identical.
// Windows reports renaming onto an existing directory as EPERM.
const code = (e as NodeJS.ErrnoException).code
const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target)
if (!lost) throw e
if (!fs.existsSync(path.join(target, lib))) {
throw new Error(`another process partially populated ${target} but libsimplex is missing; remove the directory manually and retry`)
}
}
} finally {
await fs.promises.rm(tmp, {recursive: true, force: true})
}
}
function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
const get = url.startsWith("https:") ? https.get : http.get
// Once a response arrives, request errors are ignored: a download settles via pipeline, which
// waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows.
let res: http.IncomingMessage | undefined
const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => {
res = response
const status = response.statusCode ?? 0
const location = response.headers.location
if (status >= 300 && status < 400 && location) {
response.resume()
if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`))
let next: URL
try {
next = new URL(location, url)
} catch (e) {
return reject(e)
}
if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`))
download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject)
return
}
if (status !== 200) {
response.resume()
return reject(new Error(`HTTP ${status} downloading ${url}`))
}
pipeline(response, fs.createWriteStream(dest)).then(resolve, reject)
})
const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err))
req.on("timeout", () => abort(new Error(`timeout downloading ${url}`)))
req.on("error", e => { if (!res) reject(e) })
})
}
@@ -0,0 +1,357 @@
import * as fs from "fs"
import * as http from "http"
import * as https from "https"
import * as os from "os"
import * as path from "path"
import {AddressInfo} from "net"
import {cacheRoot, installLibs, LIBS_VERSION, libPath, libsUrl, MAX_REDIRECTS, platformTag, resolveLibsDir} from "../src/libs"
import {main, parseInstallArgs} from "../src/cli"
import * as libs from "../src/libs"
import {storedZip} from "./zip"
const LIB = "libtest.so"
const LINUX_LIB = "libsimplex.so"
const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR} = process.env
function setEnv(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
// A path resolution regression must neither download nor write into the real user cache.
let home: string
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-"))
jest.spyOn(os, "homedir").mockReturnValue(home)
jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") })
setEnv("XDG_CACHE_HOME", home)
setEnv("LOCALAPPDATA", home)
setEnv("SIMPLEX_LIBS_DIR", undefined)
})
afterEach(() => {
jest.restoreAllMocks()
setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME)
setEnv("LOCALAPPDATA", LOCALAPPDATA)
setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR)
fs.rmSync(home, {recursive: true, force: true})
})
describe("paths", () => {
let libsDir: string
beforeEach(() => {
libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-"))
fs.writeFileSync(libPath(libsDir), "lib")
})
afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true}))
it("uses the Python cache layout", () => {
expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat"))
expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat"))
expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat"))
expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat"))
expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set")
})
it.each([
["linux", "x64", "linux-x86_64"],
["linux", "arm64", "linux-aarch64"],
["darwin", "x64", "macos-x86_64"],
["darwin", "arm64", "macos-aarch64"],
["win32", "x64", "windows-x86_64"],
])("tags %s/%s as %s", (platform, arch, tag) => {
expect(platformTag(platform, arch)).toBe(tag)
})
it("rejects unsupported platforms", () => {
expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform")
})
it("rejects an unsupported OS with its arch", async () => {
await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ")
})
it("uses the release URLs", () => {
const release = `https://github.com/simplex-chat/simplex-chat-libs/releases/download/v${LIBS_VERSION}`
expect(libsUrl("sqlite", "macos-aarch64")).toBe(`${release}/simplex-chat-libs-macos-aarch64.zip`)
expect(libsUrl("postgres", "linux-x86_64")).toBe(`${release}/simplex-chat-libs-linux-x86_64-postgres.zip`)
})
it("resolves an absolute SIMPLEX_LIBS_DIR", async () => {
await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir)
})
it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => {
fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir)
})
it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => {
fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir)
})
it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => {
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir)
})
it.each([
["linux", "x64", "libsimplex.so"],
["darwin", "arm64", "libsimplex.dylib"],
["win32", "x64", "libsimplex.dll"],
])("looks for the %s library name", async (platform, arch, lib) => {
fs.rmSync(libPath(libsDir))
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `)
})
it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => {
fs.rmSync(libPath(libsDir))
await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`)
})
})
describe("installLibs", () => {
let server: http.Server
let base: string
let tmp: string
const routes: {[p: string]: (res: http.ServerResponse) => void} = {}
beforeAll(async () => {
server = http.createServer((req, res) => {
const route = routes[req.url ?? ""]
if (route) route(res)
else res.writeHead(404).end()
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})
afterAll(() => new Promise<void>(resolve => server.close(() => resolve())))
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) })
afterEach(() => fs.rmSync(tmp, {recursive: true, force: true}))
const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"})
routes["/good.zip"] = res => res.writeHead(200).end(good)
routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end()
routes["/nolib.zip"] = res => res.writeHead(200).end(storedZip({"other/file": "x"}))
routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) }
let loopRequests = 0
routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() }
routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end()
routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end()
routes["/no-location.zip"] = res => res.writeHead(302).end()
routes["/hang.zip"] = () => {}
routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end()
let downloadDirs: string[] = []
routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) }
function leftovers(): string[] {
return fs.readdirSync(tmp).filter(f => f.startsWith(".download-"))
}
// Serves https requests from a local http route (the requested path by default), recording the requested URLs.
function mockHttps(route?: string): string[] {
const requested: string[] = []
jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => {
requested.push(url)
return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb)
}) as unknown as typeof https.get)
return requested
}
function renameFails(code: string): void {
jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code}))
}
beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) })
it("follows a redirect and installs libs/", async () => {
const target = path.join(tmp, "v1", "sqlite")
await installLibs(`${base}/redirect.zip`, target, LIB)
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true)
})
it("downloads into a temp dir next to the target", async () => {
await installLibs(`${base}/observed.zip`, path.join(tmp, "sqlite"), LIB)
expect(downloadDirs).toHaveLength(1)
expect(leftovers()).toEqual([])
})
it("stops following a redirect loop", async () => {
loopRequests = 0
await expect(installLibs(`${base}/loop.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects")
expect(loopRequests).toBe(MAX_REDIRECTS + 1)
expect(leftovers()).toEqual([])
})
it("follows an https redirect to another host", async () => {
const requested = mockHttps()
const target = path.join(tmp, "sqlite")
await installLibs("https://libs.test/cross-host.zip", target, LIB)
expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"])
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
})
it("rejects a redirect from https to http", async () => {
mockHttps("/to-http.zip")
await expect(installLibs("https://libs.test/libs.zip", path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol")
expect(leftovers()).toEqual([])
})
it("rejects an invalid redirect location", async () => {
await expect(installLibs(`${base}/bad-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL")
expect(leftovers()).toEqual([])
})
it("rejects a redirect without a location", async () => {
await expect(installLibs(`${base}/no-location.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302")
expect(leftovers()).toEqual([])
})
it("rejects 404 and cleans up", async () => {
const target = path.join(tmp, "sqlite")
await expect(installLibs(`${base}/missing.zip`, target, LIB)).rejects.toThrow("HTTP 404")
expect(leftovers()).toEqual([])
expect(fs.existsSync(target)).toBe(false)
})
it("rejects a zip without the lib", async () => {
const target = path.join(tmp, "sqlite")
await expect(installLibs(`${base}/nolib.zip`, target, LIB)).rejects.toThrow(`libs/${LIB} missing`)
expect(leftovers()).toEqual([])
})
it("times out a request without a response", async () => {
await expect(installLibs(`${base}/hang.zip`, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout")
expect(leftovers()).toEqual([])
})
it("times out a stalled download", async () => {
const target = path.join(tmp, "sqlite")
await expect(installLibs(`${base}/stall.zip`, target, LIB, 200)).rejects.toThrow("timeout")
expect(leftovers()).toEqual([])
})
it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => {
const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"})
routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip)
const requested = mockHttps("/libsimplex.zip")
const cache = path.relative(process.cwd(), path.join(tmp, "cache"))
const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64")
expect(requested).toEqual([libsUrl(backend, "linux-x86_64")])
expect(path.isAbsolute(dir)).toBe(true)
expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", `v${LIBS_VERSION}`, backend))
expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib")
})
it("does not download a cached lib", async () => {
const requested = mockHttps("/good.zip")
const cached = path.join(tmp, "simplex-chat", `v${LIBS_VERSION}`, "postgres")
fs.mkdirSync(cached, {recursive: true})
fs.writeFileSync(path.join(cached, LINUX_LIB), "lib")
await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached)
expect(requested).toEqual([])
})
it("rejects a target populated without the lib", async () => {
const target = path.join(tmp, "sqlite")
fs.mkdirSync(target)
fs.writeFileSync(path.join(target, "other"), "x")
await expect(installLibs(`${base}/good.zip`, target, LIB)).rejects.toThrow(`another process partially populated ${target}`)
expect(leftovers()).toEqual([])
})
it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => {
const requested = mockHttps("/good.zip")
await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64")
expect(requested).toEqual([])
})
it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => {
const target = path.join(tmp, "sqlite")
fs.mkdirSync(target)
fs.writeFileSync(path.join(target, LIB), "lib")
renameFails(code)
await expect(installLibs(`${base}/good.zip`, target, LIB)).resolves.toBeUndefined()
expect(leftovers()).toEqual([])
})
it("rethrows EPERM from rename when the target does not exist", async () => {
renameFails("EPERM")
await expect(installLibs(`${base}/good.zip`, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM")
expect(leftovers()).toEqual([])
})
it("lets concurrent installs share one target", async () => {
const target = path.join(tmp, "sqlite")
await Promise.all([
installLibs(`${base}/good.zip`, target, LIB),
installLibs(`${base}/good.zip`, target, LIB),
])
expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
expect(leftovers()).toEqual([])
})
})
describe("cli", () => {
function installWithLibsDir(dir: string): Promise<number> {
setEnv("SIMPLEX_LIBS_DIR", dir)
return main(["install"])
}
it("parses install arguments", () => {
expect(parseInstallArgs(["install"])).toBe("sqlite")
expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres")
expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres")
expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql")
expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing")
expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'")
expect(() => parseInstallArgs(["run"])).toThrow("expected command: install")
expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install")
expect(() => parseInstallArgs([])).toThrow("expected command: install")
})
it("prints the libs directory", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
fs.writeFileSync(libPath(dir), "lib")
const log = jest.spyOn(console, "log").mockImplementation(() => {})
try {
await expect(installWithLibsDir(dir)).resolves.toBe(0)
expect(log).toHaveBeenCalledWith(`libsimplex installed at: ${dir}`)
} finally {
fs.rmSync(dir, {recursive: true, force: true})
}
})
it("returns 1 when install fails", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
const error = jest.spyOn(console, "error").mockImplementation(() => {})
try {
await expect(installWithLibsDir(dir)).resolves.toBe(1)
expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no"))
} finally {
fs.rmSync(dir, {recursive: true, force: true})
}
})
it("installs the requested backend", async () => {
const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs")
jest.spyOn(console, "log").mockImplementation(() => {})
await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0)
expect(resolve).toHaveBeenCalledWith("postgres")
})
it.each(["--help", "-h"])("prints usage on %s", async flag => {
const log = jest.spyOn(console, "log").mockImplementation(() => {})
await expect(main(["install", flag])).resolves.toBe(0)
expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]")
})
it("returns 2 with usage on invalid arguments", async () => {
const error = jest.spyOn(console, "error").mockImplementation(() => {})
await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2)
expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]")
})
})
+47
View File
@@ -0,0 +1,47 @@
import * as zlib from "zlib"
const LOCAL_FILE_HEADER = 0x04034b50
const CENTRAL_DIRECTORY_HEADER = 0x02014b50
const END_OF_CENTRAL_DIRECTORY = 0x06054b50
const ZIP_VERSION = 20
const LOCAL_HEADER_SIZE = 30
const CENTRAL_ENTRY_SIZE = 46
const END_RECORD_SIZE = 22
export function storedZip(files: {[name: string]: string}): Buffer {
const local: Buffer[] = []
const central: Buffer[] = []
let offset = 0
for (const [name, content] of Object.entries(files)) {
const nameBuf = Buffer.from(name)
const data = Buffer.from(content)
const crc = zlib.crc32(data)
const header = Buffer.alloc(LOCAL_HEADER_SIZE)
header.writeUInt32LE(LOCAL_FILE_HEADER, 0)
header.writeUInt16LE(ZIP_VERSION, 4)
header.writeUInt32LE(crc, 14)
header.writeUInt32LE(data.length, 18)
header.writeUInt32LE(data.length, 22)
header.writeUInt16LE(nameBuf.length, 26)
local.push(header, nameBuf, data)
const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE)
entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0)
entry.writeUInt16LE(ZIP_VERSION, 4)
entry.writeUInt16LE(ZIP_VERSION, 6)
entry.writeUInt32LE(crc, 16)
entry.writeUInt32LE(data.length, 20)
entry.writeUInt32LE(data.length, 24)
entry.writeUInt16LE(nameBuf.length, 28)
entry.writeUInt32LE(offset, 42)
central.push(entry, nameBuf)
offset += header.length + nameBuf.length + data.length
}
const centralBuf = Buffer.concat(central)
const end = Buffer.alloc(END_RECORD_SIZE)
end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0)
end.writeUInt16LE(Object.keys(files).length, 8)
end.writeUInt16LE(Object.keys(files).length, 10)
end.writeUInt32LE(centralBuf.length, 12)
end.writeUInt32LE(offset, 16)
return Buffer.concat([...local, centralBuf, end])
}