Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
43 changes: 23 additions & 20 deletions packages/opencode/src/npm/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
Expand All @@ -8,10 +7,14 @@ import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { Arborist } from "@npmcli/arborist"
import Config from "@npmcli/config"
// @ts-ignore documented @npmcli/config integration uses this subpath, but @types/npmcli__config does not declare it
import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions"

export namespace Npm {
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
const npmdir = import.meta.dirname

export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
Expand Down Expand Up @@ -41,24 +44,20 @@ export namespace Npm {
return result
}

export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}

const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}

const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)

return semver.lt(cachedVersion, latestVersion)
async function opts(cwd: string, env = process.env) {
const conf = new Config({
cwd,
env,
argv: [],
execPath: process.execPath,
platform: process.platform,
npmPath: npmdir,
definitions,
shorthands,
flatten,
})
await conf.load()
return conf.flat
}

export async function add(pkg: string) {
Expand All @@ -68,7 +67,9 @@ export namespace Npm {
pkg,
})

const cfg = await opts(Global.Path.cache)
const arborist = new Arborist({
...cfg,
path: dir,
binLinks: true,
progress: false,
Expand All @@ -89,7 +90,7 @@ export namespace Npm {
save: true,
saveType: "prod",
})
.catch((cause) => {
.catch((cause: unknown) => {
throw new InstallFailedError(
{ pkg },
{
Expand All @@ -108,7 +109,9 @@ export namespace Npm {
log.info("checking dependencies", { dir })

const reify = async () => {
const cfg = await opts(dir)
const arb = new Arborist({
...cfg,
path: dir,
binLinks: true,
progress: false,
Expand Down
102 changes: 102 additions & 0 deletions packages/opencode/test/npm/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { tmpdir } from "../fixture/fixture"

const base = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-npm-"))
const xdg = path.join(base, "xdg")
const home = path.join(base, "home")
const prev = {
HOME: process.env.HOME,
NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG,
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
XDG_STATE_HOME: process.env.XDG_STATE_HOME,
npm_config_registry: process.env.npm_config_registry,
npm_config_userconfig: process.env.npm_config_userconfig,
}

await fs.mkdir(xdg, { recursive: true })
await fs.mkdir(home, { recursive: true })

process.env.HOME = home
process.env.XDG_CACHE_HOME = path.join(xdg, "cache")
process.env.XDG_CONFIG_HOME = path.join(xdg, "config")
process.env.XDG_DATA_HOME = path.join(xdg, "data")
process.env.XDG_STATE_HOME = path.join(xdg, "state")

const seen: Array<Record<string, unknown>> = []

mock.module("@npmcli/arborist", () => ({
Arborist: class {
constructor(input: Record<string, unknown>) {
seen.push(input)
}

async loadVirtual() {
return undefined
}

async reify() {
return {
edgesOut: new Map([["pkg", { to: { name: "@tngtech/opencode-skainet", path: base } }]]),
}
}
},
}))

const { Global } = await import("../../src/global")
const { Npm } = await import("../../src/npm")

beforeEach(async () => {
seen.length = 0
delete process.env.NPM_CONFIG_USERCONFIG
delete process.env.npm_config_registry
delete process.env.npm_config_userconfig
await fs.rm(home, { recursive: true, force: true })
await fs.mkdir(home, { recursive: true })
await fs.rm(Global.Path.cache, { recursive: true, force: true })
await fs.mkdir(Global.Path.cache, { recursive: true })
await fs.writeFile(path.join(Global.Path.cache, "version"), "21")
})

afterAll(async () => {
for (const [key, value] of Object.entries(prev)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
await fs.rm(base, { recursive: true, force: true })
})

describe("npm", () => {
test("add reads scoped registry from user npmrc", async () => {
await fs.writeFile(path.join(home, ".npmrc"), "@tngtech:registry=https://user.example/\n")

await Npm.add("@tngtech/opencode-skainet@latest")

expect(seen[0]?.["@tngtech:registry"]).toBe("https://user.example/")
expect(seen[0]?.path).toBe(
path.join(Global.Path.cache, "packages", Npm.sanitize("@tngtech/opencode-skainet@latest")),
)
})

test("add keeps cache root npmrc as local config", async () => {
await fs.writeFile(path.join(Global.Path.cache, ".npmrc"), "@tngtech:registry=https://cache.example/\n")

await Npm.add("@tngtech/opencode-skainet@latest")

expect(seen[0]?.["@tngtech:registry"]).toBe("https://cache.example/")
})

test("install reads local npmrc from install dir", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, ".npmrc"), "registry=https://dir.example/\n")

await Npm.install(tmp.path)

expect(seen[0]?.registry).toBe("https://dir.example/")
expect(seen[0]?.path).toBe(tmp.path)
})
})
Loading