| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- import { describe, expect } from "bun:test"
- import { makeGlobalNode } from "@kirincode-ai/core/effect/app-node"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
- import { Effect, Layer, Stream } from "effect"
- import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
- import { Installation } from "../../src/installation"
- import { InstallationChannel } from "@kirincode-ai/core/installation/version"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { testEffect } from "../lib/effect"
- const encoder = new TextEncoder()
- function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
- const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
- return Layer.succeed(HttpClient.HttpClient, client)
- }
- function mockSpawner(
- handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () =>
- "",
- ) {
- const spawner = ChildProcessSpawner.make((command) => {
- const std = ChildProcess.isStandardCommand(command) ? command : undefined
- const result = handler(std?.command ?? "", std?.args ?? [])
- const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result
- return Effect.succeed(
- ChildProcessSpawner.makeHandle({
- pid: ChildProcessSpawner.ProcessId(0),
- exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)),
- isRunning: Effect.succeed(false),
- kill: () => Effect.void,
- stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
- stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty,
- stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty,
- all: Stream.empty,
- getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
- getOutputFd: () => Stream.empty,
- unref: Effect.succeed(Effect.void),
- }),
- )
- })
- return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
- }
- function jsonResponse(body: unknown) {
- return new Response(JSON.stringify(body), {
- status: 200,
- headers: { "content-type": "application/json" },
- })
- }
- function testLayer(
- httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
- spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string },
- ) {
- const spawnerNode = makeGlobalNode({
- service: ChildProcessSpawner.ChildProcessSpawner,
- layer: mockSpawner(spawnHandler),
- deps: [],
- })
- return LayerNode.compile(Installation.node, [
- [httpClient, mockHttpClient(httpHandler)],
- [CrossSpawnSpawner.node, spawnerNode],
- ])
- }
- describe("installation", () => {
- describe("latest", () => {
- testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect(
- "reads release version from GitHub releases",
- () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("unknown")
- expect(result).toBe("1.2.3")
- }),
- )
- testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect(
- "strips v prefix from GitHub release tag",
- () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("curl")
- expect(result).toBe("4.0.0-beta.1")
- }),
- )
- const npmCalls: string[] = []
- testEffect(
- testLayer((request) => {
- npmCalls.push(request.url)
- return jsonResponse({ version: "1.5.0" })
- }),
- ).effect("reads npm versions via registry", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("npm")
- expect(result).toBe("1.5.0")
- expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
- }),
- )
- const bunCalls: string[] = []
- testEffect(
- testLayer((request) => {
- bunCalls.push(request.url)
- return jsonResponse({ version: "1.6.0" })
- }),
- ).effect("reads bun versions via registry", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("bun")
- expect(result).toBe("1.6.0")
- expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
- }),
- )
- const pnpmCalls: string[] = []
- testEffect(
- testLayer((request) => {
- pnpmCalls.push(request.url)
- return jsonResponse({ version: "1.7.0" })
- }),
- ).effect("reads pnpm versions via registry", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("pnpm")
- expect(result).toBe("1.7.0")
- expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
- }),
- )
- testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("scoop")
- expect(result).toBe("2.3.4")
- }),
- )
- testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect(
- "reads chocolatey feed versions",
- () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("choco")
- expect(result).toBe("3.4.5")
- }),
- )
- testEffect(
- testLayer(
- () => jsonResponse({ versions: { stable: "2.0.0" } }),
- (cmd, args) => {
- // getBrewFormula: return core formula (no tap)
- if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
- if (cmd === "brew" && args.includes("--formula") && args.includes("kirincode")) return "kirincode"
- return ""
- },
- ),
- ).effect("reads brew formulae API versions", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("brew")
- expect(result).toBe("2.0.0")
- }),
- )
- const brewInfoJson = JSON.stringify({
- formulae: [{ versions: { stable: "2.1.0" } }],
- })
- testEffect(
- testLayer(
- () => jsonResponse({}), // HTTP not used for tap formula
- (cmd, args) => {
- if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "kirincode"
- if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
- return ""
- },
- ),
- ).effect("reads brew tap info JSON via CLI", () =>
- Effect.gen(function* () {
- const result = yield* Installation.use.latest("brew")
- expect(result).toBe("2.1.0")
- }),
- )
- })
- describe("upgrade", () => {
- testEffect(
- testLayer(
- () => jsonResponse({}),
- (cmd) => {
- if (cmd === "npm") return { code: 1, stderr: "token=secret command output" }
- return ""
- },
- ),
- ).effect("returns sanitized typed errors for failed package upgrades", () =>
- Effect.gen(function* () {
- const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9"))
- expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
- expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).")
- expect(error.message).toBe(error.stderr)
- expect(error.stderr).not.toContain("secret")
- expect(error.stderr).not.toContain("command output")
- }),
- )
- testEffect(
- testLayer(
- () => new Response("install script with token=secret", { status: 200 }),
- (cmd, args) => {
- if (cmd === "bash" && args[0] === "--version") return "GNU bash"
- if (cmd === "bash" || cmd === "sh") return { code: 1, stderr: "script output with token=secret" }
- return ""
- },
- ),
- ).effect("returns sanitized typed errors when the curl install script fails", () =>
- Effect.gen(function* () {
- const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9"))
- expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
- expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).")
- expect(error.message).toBe(error.stderr)
- expect(error.stderr).not.toContain("secret")
- expect(error.stderr).not.toContain("script output")
- }),
- )
- testEffect(
- testLayer(
- () => new Response("install script", { status: 200 }),
- (cmd, args) => {
- if (cmd === "bash" && args[0] === "--version") return { code: 1, stderr: "missing" }
- if (cmd === "bash") return { code: 1, stderr: "should not execute installer with bash" }
- if (cmd === "sh") return "ok"
- return ""
- },
- ),
- ).effect("falls back to sh when bash is unavailable during curl upgrade", () =>
- Effect.gen(function* () {
- yield* Installation.use.upgrade("curl", "9.9.9")
- }),
- )
- })
- })
|