Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/core/src/npm-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
},
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => ({}) as Record<string, unknown>))

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
}),
)
86 changes: 3 additions & 83 deletions packages/core/src/npm.ts
Original file line number Diff line number Diff line change
@@ -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<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
Expand All @@ -40,46 +32,18 @@ export interface Interface {
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
}

export class Service extends Context.Service<Service, Interface>()("@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<string>
try {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -309,7 +235,6 @@ export const layer = Layer.effect(
return Service.of({
add,
install,
outdated,
which,
})
}),
Expand All @@ -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)
Expand All @@ -337,10 +261,6 @@ export async function add(...args: Parameters<Interface["add"]>) {
}
}

export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}

export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
Expand Down
3 changes: 1 addition & 2 deletions packages/opencode/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 3 additions & 8 deletions packages/opencode/src/cli/cmd/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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`)
Expand All @@ -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`)
Expand All @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(() => {})
}
29 changes: 15 additions & 14 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -132,18 +133,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => 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"
Expand Down Expand Up @@ -235,7 +224,13 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
}

if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}

if (detectedMethod === "choco") {
Expand Down Expand Up @@ -335,4 +330,10 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
)

const { runPromise } = makeRuntime(Service, defaultLayer)

export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))

export * as Installation from "."
1 change: 0 additions & 1 deletion packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/test/npm-config.test.ts
Original file line number Diff line number Diff line change
@@ -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/fixture"

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")
})
})
Loading
Loading