diff --git a/packages/core/package.json b/packages/core/package.json index d18acf3c73..546dc576c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "test": "bun test", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/core/src/npm-config.ts b/packages/core/src/npm-config.ts new file mode 100644 index 0000000000..896bb84872 --- /dev/null +++ b/packages/core/src/npm-config.ts @@ -0,0 +1,40 @@ +export * as NpmConfig from "./npm-config" + +import { fileURLToPath } from "url" +// @ts-expect-error npm does not publish types for this internal config API. +import Config from "@npmcli/config" +// @ts-expect-error npm does not publish types for this internal config API. +import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js" +import { Effect } from "effect" + +const npmPath = fileURLToPath(new URL("..", import.meta.url)) + +export const load = (dir: string) => + Effect.tryPromise({ + try: async () => { + const config = new Config({ + npmPath, + cwd: dir, + env: { ...process.env }, + argv: [process.execPath, process.execPath], + execPath: process.execPath, + platform: process.platform, + definitions, + flatten, + nerfDarts, + shorthands, + warn: false, + }) + await config.load() + return config.flat as Record + }, + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => ({}) as Record)) + +export const registry = (dir: string) => + load(dir).pipe( + Effect.map((config) => { + const registry = typeof config.registry === "string" ? config.registry : "https://registry.npmjs.org" + return registry.endsWith("/") ? registry.slice(0, -1) : registry + }), + ) diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index a52e0a9a51..92e4042768 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -1,22 +1,14 @@ export * as Npm from "./npm" import path from "path" -import { fileURLToPath } from "url" import npa from "npm-package-arg" -import semver from "semver" -// @ts-expect-error npm does not publish types for this internal config API. -import Config from "@npmcli/config" -// @ts-expect-error npm does not publish types for this internal config API. -import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js" -import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect" +import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "./filesystem" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" import { makeRuntime } from "./effect/runtime" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" - -import { CrossSpawnSpawner } from "./cross-spawn-spawner" +import { NpmConfig } from "./npm-config" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), @@ -40,46 +32,18 @@ export interface Interface { }[] }, ) => Effect.Effect - readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string, bin?: string) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Npm") {} const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined -const npmPath = fileURLToPath(new URL("..", import.meta.url)) export function sanitize(pkg: string) { if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } -const loadOptions = (dir: string) => - Effect.tryPromise({ - try: async () => { - const config = new Config({ - npmPath, - cwd: dir, - env: { ...process.env }, - argv: [process.execPath, process.execPath], - execPath: process.execPath, - platform: process.platform, - definitions, - flatten, - nerfDarts, - shorthands, - warn: false, - }) - await config.load() - return config.flat - }, - catch: (cause) => - new InstallFailedError({ - cause, - dir, - }), - }) - const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: Option.Option try { @@ -110,39 +74,13 @@ export const layer = Layer.effect( const global = yield* Global.Service const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) - const runView = Effect.fnUntraced(function* (cmd: string[]) { - const handle = yield* spawner.spawn( - ChildProcess.make(cmd[0], cmd.slice(1), { - extendEnv: true, - }), - ) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - if (code !== 0 || !stdout.trim()) { - return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`) - } - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout) - }, Effect.scoped) - const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) { - return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe( - Effect.catch(() => - runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe( - Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])), - ), - ), - ) - }) const reify = (input: { dir: string; add?: string[] }) => Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const add = input.add ?? [] - const npmOptions = yield* loadOptions(input.dir) + const npmOptions = yield* NpmConfig.load(input.dir) const arborist = new Arborist({ ...npmOptions, path: input.dir, @@ -172,18 +110,6 @@ export const layer = Layer.effect( }), ) - const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { - const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option) - if (Option.isNone(latestVersion)) { - return false - } - - const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion.value, cachedVersion) - - return semver.lt(cachedVersion, latestVersion.value) - }) - const add = Effect.fn("Npm.add")(function* (pkg: string) { const dir = directory(pkg) const name = (() => { @@ -309,7 +235,6 @@ export const layer = Layer.effect( return Service.of({ add, install, - outdated, which, }) }), @@ -320,7 +245,6 @@ export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.layer), Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -337,10 +261,6 @@ export async function add(...args: Parameters) { } } -export async function outdated(...args: Parameters) { - return runPromise((svc) => svc.outdated(...args)) -} - export async function which(...args: Parameters) { const resolved = await runPromise((svc) => svc.which(...args)) return Option.getOrUndefined(resolved) diff --git a/packages/core/test/fixture/tmpdir.ts b/packages/core/test/fixture/tmpdir.ts new file mode 100644 index 0000000000..950b1401b6 --- /dev/null +++ b/packages/core/test/fixture/tmpdir.ts @@ -0,0 +1,13 @@ +import fs from "fs/promises" +import { tmpdir as osTmpdir } from "os" +import path from "path" + +export const tmpdir = async () => { + const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")) + return { + path: dir, + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} diff --git a/packages/core/test/npm-config.test.ts b/packages/core/test/npm-config.test.ts new file mode 100644 index 0000000000..895b35db54 --- /dev/null +++ b/packages/core/test/npm-config.test.ts @@ -0,0 +1,51 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { NpmConfig } from "@opencode-ai/core/npm-config" +import { tmpdir } from "./fixture/tmpdir" + +describe("NpmConfig.load", () => { + test("reads registry from project .npmrc", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n") + + const config = await Effect.runPromise(NpmConfig.load(tmp.path)) + + expect(config.registry).toBe("https://registry.example.test/") + }) + + test("reads scoped registries from project .npmrc", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n") + + const config = await Effect.runPromise(NpmConfig.load(tmp.path)) + + expect(config["@acme:registry"]).toBe("https://npm.acme.test/") + }) + + test("flattens boolean and list options", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, ".npmrc"), "ignore-scripts=true\nomit[]=dev\nomit[]=optional\n") + + const config = await Effect.runPromise(NpmConfig.load(tmp.path)) + + expect(config.ignoreScripts).toBe(true) + expect(config.omit).toEqual(["dev", "optional"]) + }) +}) + +describe("NpmConfig.registry", () => { + test("normalizes configured registry without trailing slash", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n") + + await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test") + }) + + test("leaves configured registry without trailing slash unchanged", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test\n") + + await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test") + }) +}) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts new file mode 100644 index 0000000000..3e94a08692 --- /dev/null +++ b/packages/core/test/npm.test.ts @@ -0,0 +1,56 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect, test } from "bun:test" +import { Npm } from "@opencode-ai/core/npm" +import { tmpdir } from "./fixture/tmpdir" + +const win = process.platform === "win32" + +const writePackage = (dir: string, pkg: Record) => + Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ + version: "1.0.0", + ...pkg, + }), + ) + +describe("Npm.sanitize", () => { + test("keeps normal scoped package specs unchanged", () => { + expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") + expect(Npm.sanitize("prettier")).toBe("prettier") + }) + + test("handles git https specs", () => { + const spec = "acme@git+https://github.com/opencode/acme.git" + const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec + expect(Npm.sanitize(spec)).toBe(expected) + }) +}) + +describe("Npm.install", () => { + test("respects omit from project .npmrc", async () => { + await using tmp = await tmpdir() + + await writePackage(tmp.path, { + name: "fixture", + dependencies: { + "prod-pkg": "file:./prod-pkg", + }, + devDependencies: { + "dev-pkg": "file:./dev-pkg", + }, + }) + await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n") + await fs.mkdir(path.join(tmp.path, "prod-pkg")) + await fs.mkdir(path.join(tmp.path, "dev-pkg")) + await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" }) + await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" }) + + await Npm.install(tmp.path) + + await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined() + await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() + }) +}) diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index dc076913b8..254022dd5d 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -1,7 +1,6 @@ import type { Argv } from "yargs" import { UI } from "../ui" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "../../installation" import { Global } from "@opencode-ai/core/global" import fs from "fs/promises" @@ -58,7 +57,7 @@ export const UninstallCommand = { UI.empty() prompts.intro("Uninstall OpenCode") - const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) + const method = await Installation.method() prompts.log.info(`Installation method: ${method}`) const targets = await collectRemovalTargets(args, method) diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index a60b1fb0bf..3c1604a0b8 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -1,7 +1,6 @@ import type { Argv } from "yargs" import { UI } from "../ui" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -26,7 +25,7 @@ export const UpgradeCommand = { UI.println(UI.logo(" ")) UI.empty() prompts.intro("Upgrade") - const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) + const detectedMethod = await Installation.method() const method = (args.method as Installation.Method) ?? detectedMethod if (method === "unknown") { prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`) @@ -44,9 +43,7 @@ export const UpgradeCommand = { } } prompts.log.info("Using method: " + method) - const target = args.target - ? args.target.replace(/^v/, "") - : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest())) + const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest() if (InstallationVersion === target) { prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) @@ -57,9 +54,7 @@ export const UpgradeCommand = { prompts.log.info(`From ${InstallationVersion} → ${target}`) const spinner = prompts.spinner() spinner.start("Upgrading...") - const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( - (err) => err, - ) + const err = await Installation.upgrade(method, target).catch((err) => err) if (err) { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index da0451c55c..18f6bc45f5 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -8,8 +8,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return - const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) - const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) + const method = await Installation.method() + const latest = await Installation.latest(method).catch(() => {}) if (!latest) return if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { @@ -27,7 +27,7 @@ export async function upgrade() { } if (method === "unknown") return - await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest))) + await Installation.upgrade(method, latest) .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) .catch(() => {}) } diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index ca3a2ea920..35bb310e02 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -8,9 +8,10 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@opencode-ai/core/flag/flag" import { Log } from "../util" - +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { NpmConfig } from "@opencode-ai/core/npm-config" const log = Log.create({ service: "installation" }) @@ -132,18 +133,6 @@ export const layer: Layer.Layer Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), ) - // Use the package manager's resolver so registries, mirrors, auth, proxies, and dist-tags match upgrade behavior. - const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) { - const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"] - const result = yield* run([method, ...args]) - if (result.code !== 0 || !result.stdout.trim()) { - return yield* new UpgradeFailedError({ - stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`, - }) - } - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout) - }) - const getBrewFormula = Effect.fnUntraced(function* () { const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" @@ -235,7 +224,13 @@ export const layer: Layer.Layer) => runPromise((s) => s.latest(...args)) +export const method = () => runPromise((s) => s.method()) +export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) + export * as Installation from "." diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index aad9d7e7af..24c29fd1e0 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -988,7 +988,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const noopNpm = Layer.mock(Npm.Service)({ install: () => Effect.void, add: () => Effect.die("not implemented"), - outdated: () => Effect.succeed(false), which: () => Effect.succeed(Option.none()), }) const testLayer = Config.layer.pipe( diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 469ebb714d..5b26b05655 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -69,64 +69,46 @@ describe("installation", () => { expect(result).toBe("4.0.0-beta.1") }) - test("reads npm versions via npm view", async () => { - const calls: string[][] = [] - const layer = testLayer( - () => { - throw new Error("unexpected http request") - }, - (cmd, args) => { - calls.push([cmd, ...args]) - if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n' - return "" - }, - ) + test("reads npm versions via registry", async () => { + const calls: string[] = [] + const layer = testLayer((request) => { + calls.push(request.url) + return jsonResponse({ version: "1.5.0" }) + }) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.5.0") - expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) + expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) }) - test("reads npm versions via bun pm view", async () => { - const calls: string[][] = [] - const layer = testLayer( - () => { - throw new Error("unexpected http request") - }, - (cmd, args) => { - calls.push([cmd, ...args]) - if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n' - return "" - }, - ) + test("reads bun versions via registry", async () => { + const calls: string[] = [] + const layer = testLayer((request) => { + calls.push(request.url) + return jsonResponse({ version: "1.6.0" }) + }) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.6.0") - expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) + expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) }) - test("reads npm versions via pnpm view", async () => { - const calls: string[][] = [] - const layer = testLayer( - () => { - throw new Error("unexpected http request") - }, - (cmd, args) => { - calls.push([cmd, ...args]) - if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n' - return "" - }, - ) + test("reads pnpm versions via registry", async () => { + const calls: string[] = [] + const layer = testLayer((request) => { + calls.push(request.url) + return jsonResponse({ version: "1.7.0" }) + }) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.7.0") - expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) + expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) }) test("reads scoop manifest versions", async () => { diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts deleted file mode 100644 index d5b93a83c0..0000000000 --- a/packages/opencode/test/npm.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import { describe, expect, test } from "bun:test" -import { Effect, Layer, Stream } from "effect" -import { NodeFileSystem } from "@effect/platform-node" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Global } from "@opencode-ai/core/global" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { Npm } from "@opencode-ai/core/npm" -import { tmpdir } from "./fixture/fixture" - -const win = process.platform === "win32" -const encoder = new TextEncoder() -function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") { - const spawner = ChildProcessSpawner.make((command) => { - const std = ChildProcess.isStandardCommand(command) ? command : undefined - const output = handler(std?.command ?? "", std?.args ?? []) - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(0), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, - stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, - 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 testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) { - return Npm.layer.pipe( - Layer.provide(mockSpawner(spawnHandler)), - Layer.provide(EffectFlock.layer), - Layer.provide(AppFileSystem.layer), - Layer.provide(Global.layer), - Layer.provide(NodeFileSystem.layer), - ) -} - -const writePackage = (dir: string, pkg: Record) => - Bun.write( - path.join(dir, "package.json"), - JSON.stringify({ - version: "1.0.0", - ...pkg, - }), - ) - -describe("Npm.sanitize", () => { - test("keeps normal scoped package specs unchanged", () => { - expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") - expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") - expect(Npm.sanitize("prettier")).toBe("prettier") - }) - - test("handles git https specs", () => { - const spec = "acme@git+https://github.com/opencode/acme.git" - const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec - expect(Npm.sanitize(spec)).toBe(expected) - }) -}) - -describe("Npm.install", () => { - test("respects omit from project .npmrc", async () => { - await using tmp = await tmpdir() - - await writePackage(tmp.path, { - name: "fixture", - dependencies: { - "prod-pkg": "file:./prod-pkg", - }, - devDependencies: { - "dev-pkg": "file:./dev-pkg", - }, - }) - await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n") - await fs.mkdir(path.join(tmp.path, "prod-pkg")) - await fs.mkdir(path.join(tmp.path, "dev-pkg")) - await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" }) - await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" }) - - await Npm.install(tmp.path) - - await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined() - await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() - }) -}) - -describe("Npm.outdated", () => { - test("checks latest via npm view", async () => { - const calls: string[][] = [] - const layer = testLayer((cmd, args) => { - calls.push([cmd, ...args]) - if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n' - return "" - }) - - const result = await Effect.runPromise( - Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)), - ) - - expect(result).toBe(true) - expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) - }) - - test("keeps range comparison behavior", async () => { - const layer = testLayer((cmd, args) => { - if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n' - return "" - }) - - const result = await Effect.runPromise( - Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)), - ) - - expect(result).toBe(false) - }) - - test("falls back when npm view is unavailable", async () => { - const calls: string[][] = [] - const layer = testLayer((cmd, args) => { - calls.push([cmd, ...args]) - if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n' - return "" - }) - - const result = await Effect.runPromise( - Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)), - ) - - expect(result).toBe(true) - expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) - expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"]) - }) -})