Refactor npm config handling (#24565)
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
|
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -1,22 +1,14 @@
|
|||||||
export * as Npm from "./npm"
|
export * as Npm from "./npm"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import npa from "npm-package-arg"
|
import npa from "npm-package-arg"
|
||||||
import semver from "semver"
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
// @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 { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "./filesystem"
|
import { AppFileSystem } from "./filesystem"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { EffectFlock } from "./util/effect-flock"
|
import { EffectFlock } from "./util/effect-flock"
|
||||||
import { makeRuntime } from "./effect/runtime"
|
import { makeRuntime } from "./effect/runtime"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { NpmConfig } from "./npm-config"
|
||||||
|
|
||||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
|
||||||
|
|
||||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||||
@@ -40,46 +32,18 @@ export interface Interface {
|
|||||||
}[]
|
}[]
|
||||||
},
|
},
|
||||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
) => 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>>
|
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||||
|
|
||||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||||
const npmPath = fileURLToPath(new URL("..", import.meta.url))
|
|
||||||
|
|
||||||
export function sanitize(pkg: string) {
|
export function sanitize(pkg: string) {
|
||||||
if (!illegal) return pkg
|
if (!illegal) return pkg
|
||||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
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 => {
|
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||||
let entrypoint: Option.Option<string>
|
let entrypoint: Option.Option<string>
|
||||||
try {
|
try {
|
||||||
@@ -110,39 +74,13 @@ export const layer = Layer.effect(
|
|||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const flock = yield* EffectFlock.Service
|
const flock = yield* EffectFlock.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
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[] }) =>
|
const reify = (input: { dir: string; add?: string[] }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||||
const add = input.add ?? []
|
const add = input.add ?? []
|
||||||
const npmOptions = yield* loadOptions(input.dir)
|
const npmOptions = yield* NpmConfig.load(input.dir)
|
||||||
const arborist = new Arborist({
|
const arborist = new Arborist({
|
||||||
...npmOptions,
|
...npmOptions,
|
||||||
path: input.dir,
|
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 add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||||
const dir = directory(pkg)
|
const dir = directory(pkg)
|
||||||
const name = (() => {
|
const name = (() => {
|
||||||
@@ -309,7 +235,6 @@ export const layer = Layer.effect(
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
add,
|
add,
|
||||||
install,
|
install,
|
||||||
outdated,
|
|
||||||
which,
|
which,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -320,7 +245,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(AppFileSystem.layer),
|
Layer.provide(AppFileSystem.layer),
|
||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
@@ -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"]>) {
|
export async function which(...args: Parameters<Interface["which"]>) {
|
||||||
const resolved = await runPromise((svc) => svc.which(...args))
|
const resolved = await runPromise((svc) => svc.which(...args))
|
||||||
return Option.getOrUndefined(resolved)
|
return Option.getOrUndefined(resolved)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, unknown>) =>
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
@@ -58,7 +57,7 @@ export const UninstallCommand = {
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Uninstall OpenCode")
|
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}`)
|
prompts.log.info(`Installation method: ${method}`)
|
||||||
|
|
||||||
const targets = await collectRemovalTargets(args, method)
|
const targets = await collectRemovalTargets(args, method)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ export const UpgradeCommand = {
|
|||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
UI.empty()
|
UI.empty()
|
||||||
prompts.intro("Upgrade")
|
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
|
const method = (args.method as Installation.Method) ?? detectedMethod
|
||||||
if (method === "unknown") {
|
if (method === "unknown") {
|
||||||
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
|
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)
|
prompts.log.info("Using method: " + method)
|
||||||
const target = args.target
|
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
|
||||||
? args.target.replace(/^v/, "")
|
|
||||||
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
|
||||||
|
|
||||||
if (InstallationVersion === target) {
|
if (InstallationVersion === target) {
|
||||||
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
||||||
@@ -57,9 +54,7 @@ export const UpgradeCommand = {
|
|||||||
prompts.log.info(`From ${InstallationVersion} → ${target}`)
|
prompts.log.info(`From ${InstallationVersion} → ${target}`)
|
||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
spinner.start("Upgrading...")
|
spinner.start("Upgrading...")
|
||||||
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||||
(err) => err,
|
|
||||||
)
|
|
||||||
if (err) {
|
if (err) {
|
||||||
spinner.stop("Upgrade failed", 1)
|
spinner.stop("Upgrade failed", 1)
|
||||||
if (err instanceof Installation.UpgradeFailedError) {
|
if (err instanceof Installation.UpgradeFailedError) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|||||||
export async function upgrade() {
|
export async function upgrade() {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
const method = await Installation.method()
|
||||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
const latest = await Installation.latest(method).catch(() => {})
|
||||||
if (!latest) return
|
if (!latest) return
|
||||||
|
|
||||||
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
|
||||||
@@ -27,7 +27,7 @@ export async function upgrade() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "unknown") return
|
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 }))
|
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import z from "zod"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
|
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
import { NpmConfig } from "@opencode-ai/core/npm-config"
|
||||||
|
|
||||||
const log = Log.create({ service: "installation" })
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
@@ -132,18 +133,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
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 getBrewFormula = Effect.fnUntraced(function* () {
|
||||||
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||||
@@ -235,7 +224,13 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
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") {
|
if (detectedMethod === "choco") {
|
||||||
@@ -335,4 +330,10 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
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 "."
|
export * as Installation from "."
|
||||||
|
|||||||
@@ -988,7 +988,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
const noopNpm = Layer.mock(Npm.Service)({
|
const noopNpm = Layer.mock(Npm.Service)({
|
||||||
install: () => Effect.void,
|
install: () => Effect.void,
|
||||||
add: () => Effect.die("not implemented"),
|
add: () => Effect.die("not implemented"),
|
||||||
outdated: () => Effect.succeed(false),
|
|
||||||
which: () => Effect.succeed(Option.none()),
|
which: () => Effect.succeed(Option.none()),
|
||||||
})
|
})
|
||||||
const testLayer = Config.layer.pipe(
|
const testLayer = Config.layer.pipe(
|
||||||
|
|||||||
@@ -69,64 +69,46 @@ describe("installation", () => {
|
|||||||
expect(result).toBe("4.0.0-beta.1")
|
expect(result).toBe("4.0.0-beta.1")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reads npm versions via npm view", async () => {
|
test("reads npm versions via registry", async () => {
|
||||||
const calls: string[][] = []
|
const calls: string[] = []
|
||||||
const layer = testLayer(
|
const layer = testLayer((request) => {
|
||||||
() => {
|
calls.push(request.url)
|
||||||
throw new Error("unexpected http request")
|
return jsonResponse({ version: "1.5.0" })
|
||||||
},
|
})
|
||||||
(cmd, args) => {
|
|
||||||
calls.push([cmd, ...args])
|
|
||||||
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
expect(result).toBe("1.5.0")
|
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 () => {
|
test("reads bun versions via registry", async () => {
|
||||||
const calls: string[][] = []
|
const calls: string[] = []
|
||||||
const layer = testLayer(
|
const layer = testLayer((request) => {
|
||||||
() => {
|
calls.push(request.url)
|
||||||
throw new Error("unexpected http request")
|
return jsonResponse({ version: "1.6.0" })
|
||||||
},
|
})
|
||||||
(cmd, args) => {
|
|
||||||
calls.push([cmd, ...args])
|
|
||||||
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
expect(result).toBe("1.6.0")
|
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 () => {
|
test("reads pnpm versions via registry", async () => {
|
||||||
const calls: string[][] = []
|
const calls: string[] = []
|
||||||
const layer = testLayer(
|
const layer = testLayer((request) => {
|
||||||
() => {
|
calls.push(request.url)
|
||||||
throw new Error("unexpected http request")
|
return jsonResponse({ version: "1.7.0" })
|
||||||
},
|
})
|
||||||
(cmd, args) => {
|
|
||||||
calls.push([cmd, ...args])
|
|
||||||
if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
|
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
expect(result).toBe("1.7.0")
|
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 () => {
|
test("reads scoop manifest versions", async () => {
|
||||||
|
|||||||
@@ -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<string, unknown>) =>
|
|
||||||
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"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user