Skip to content

Commit a9b62d6

Browse files
authored
Refactor npm config handling (#24565)
1 parent 3525e61 commit a9b62d6

13 files changed

Lines changed: 207 additions & 293 deletions

File tree

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"private": true,
88
"scripts": {
99
"test": "bun test",
10+
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
1011
"typecheck": "tsgo --noEmit"
1112
},
1213
"bin": {

packages/core/src/npm-config.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export * as NpmConfig from "./npm-config"
2+
3+
import { fileURLToPath } from "url"
4+
// @ts-expect-error npm does not publish types for this internal config API.
5+
import Config from "@npmcli/config"
6+
// @ts-expect-error npm does not publish types for this internal config API.
7+
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
8+
import { Effect } from "effect"
9+
10+
const npmPath = fileURLToPath(new URL("..", import.meta.url))
11+
12+
export const load = (dir: string) =>
13+
Effect.tryPromise({
14+
try: async () => {
15+
const config = new Config({
16+
npmPath,
17+
cwd: dir,
18+
env: { ...process.env },
19+
argv: [process.execPath, process.execPath],
20+
execPath: process.execPath,
21+
platform: process.platform,
22+
definitions,
23+
flatten,
24+
nerfDarts,
25+
shorthands,
26+
warn: false,
27+
})
28+
await config.load()
29+
return config.flat as Record<string, unknown>
30+
},
31+
catch: (cause) => cause,
32+
}).pipe(Effect.orElseSucceed(() => ({}) as Record<string, unknown>))
33+
34+
export const registry = (dir: string) =>
35+
load(dir).pipe(
36+
Effect.map((config) => {
37+
const registry = typeof config.registry === "string" ? config.registry : "https://registry.npmjs.org"
38+
return registry.endsWith("/") ? registry.slice(0, -1) : registry
39+
}),
40+
)

packages/core/src/npm.ts

Lines changed: 3 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
export * as Npm from "./npm"
22

33
import path from "path"
4-
import { fileURLToPath } from "url"
54
import npa from "npm-package-arg"
6-
import semver from "semver"
7-
// @ts-expect-error npm does not publish types for this internal config API.
8-
import Config from "@npmcli/config"
9-
// @ts-expect-error npm does not publish types for this internal config API.
10-
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
11-
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
5+
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
126
import { NodeFileSystem } from "@effect/platform-node"
137
import { AppFileSystem } from "./filesystem"
148
import { Global } from "./global"
159
import { EffectFlock } from "./util/effect-flock"
1610
import { makeRuntime } from "./effect/runtime"
17-
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
18-
19-
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
11+
import { NpmConfig } from "./npm-config"
2012

2113
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
2214
add: Schema.Array(Schema.String).pipe(Schema.optional),
@@ -40,46 +32,18 @@ export interface Interface {
4032
}[]
4133
},
4234
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
43-
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
4435
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
4536
}
4637

4738
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
4839

4940
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
50-
const npmPath = fileURLToPath(new URL("..", import.meta.url))
5141

5242
export function sanitize(pkg: string) {
5343
if (!illegal) return pkg
5444
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
5545
}
5646

57-
const loadOptions = (dir: string) =>
58-
Effect.tryPromise({
59-
try: async () => {
60-
const config = new Config({
61-
npmPath,
62-
cwd: dir,
63-
env: { ...process.env },
64-
argv: [process.execPath, process.execPath],
65-
execPath: process.execPath,
66-
platform: process.platform,
67-
definitions,
68-
flatten,
69-
nerfDarts,
70-
shorthands,
71-
warn: false,
72-
})
73-
await config.load()
74-
return config.flat
75-
},
76-
catch: (cause) =>
77-
new InstallFailedError({
78-
cause,
79-
dir,
80-
}),
81-
})
82-
8347
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
8448
let entrypoint: Option.Option<string>
8549
try {
@@ -110,39 +74,13 @@ export const layer = Layer.effect(
11074
const global = yield* Global.Service
11175
const fs = yield* FileSystem.FileSystem
11276
const flock = yield* EffectFlock.Service
113-
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
11477
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
115-
const runView = Effect.fnUntraced(function* (cmd: string[]) {
116-
const handle = yield* spawner.spawn(
117-
ChildProcess.make(cmd[0], cmd.slice(1), {
118-
extendEnv: true,
119-
}),
120-
)
121-
const [stdout, stderr] = yield* Effect.all(
122-
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
123-
{ concurrency: 2 },
124-
)
125-
const code = yield* handle.exitCode
126-
if (code !== 0 || !stdout.trim()) {
127-
return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
128-
}
129-
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
130-
}, Effect.scoped)
131-
const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
132-
return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
133-
Effect.catch(() =>
134-
runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
135-
Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
136-
),
137-
),
138-
)
139-
})
14078
const reify = (input: { dir: string; add?: string[] }) =>
14179
Effect.gen(function* () {
14280
yield* flock.acquire(`npm-install:${input.dir}`)
14381
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
14482
const add = input.add ?? []
145-
const npmOptions = yield* loadOptions(input.dir)
83+
const npmOptions = yield* NpmConfig.load(input.dir)
14684
const arborist = new Arborist({
14785
...npmOptions,
14886
path: input.dir,
@@ -172,18 +110,6 @@ export const layer = Layer.effect(
172110
}),
173111
)
174112

175-
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
176-
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
177-
if (Option.isNone(latestVersion)) {
178-
return false
179-
}
180-
181-
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
182-
if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
183-
184-
return semver.lt(cachedVersion, latestVersion.value)
185-
})
186-
187113
const add = Effect.fn("Npm.add")(function* (pkg: string) {
188114
const dir = directory(pkg)
189115
const name = (() => {
@@ -309,7 +235,6 @@ export const layer = Layer.effect(
309235
return Service.of({
310236
add,
311237
install,
312-
outdated,
313238
which,
314239
})
315240
}),
@@ -320,7 +245,6 @@ export const defaultLayer = layer.pipe(
320245
Layer.provide(AppFileSystem.layer),
321246
Layer.provide(Global.layer),
322247
Layer.provide(NodeFileSystem.layer),
323-
Layer.provide(CrossSpawnSpawner.defaultLayer),
324248
)
325249

326250
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -337,10 +261,6 @@ export async function add(...args: Parameters<Interface["add"]>) {
337261
}
338262
}
339263

340-
export async function outdated(...args: Parameters<Interface["outdated"]>) {
341-
return runPromise((svc) => svc.outdated(...args))
342-
}
343-
344264
export async function which(...args: Parameters<Interface["which"]>) {
345265
const resolved = await runPromise((svc) => svc.which(...args))
346266
return Option.getOrUndefined(resolved)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import fs from "fs/promises"
2+
import { tmpdir as osTmpdir } from "os"
3+
import path from "path"
4+
5+
export const tmpdir = async () => {
6+
const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))
7+
return {
8+
path: dir,
9+
async [Symbol.asyncDispose]() {
10+
await fs.rm(dir, { recursive: true, force: true })
11+
},
12+
}
13+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import path from "path"
2+
import { describe, expect, test } from "bun:test"
3+
import { Effect } from "effect"
4+
import { NpmConfig } from "@opencode-ai/core/npm-config"
5+
import { tmpdir } from "./fixture/tmpdir"
6+
7+
describe("NpmConfig.load", () => {
8+
test("reads registry from project .npmrc", async () => {
9+
await using tmp = await tmpdir()
10+
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
11+
12+
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
13+
14+
expect(config.registry).toBe("https://registry.example.test/")
15+
})
16+
17+
test("reads scoped registries from project .npmrc", async () => {
18+
await using tmp = await tmpdir()
19+
await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n")
20+
21+
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
22+
23+
expect(config["@acme:registry"]).toBe("https://npm.acme.test/")
24+
})
25+
26+
test("flattens boolean and list options", async () => {
27+
await using tmp = await tmpdir()
28+
await Bun.write(path.join(tmp.path, ".npmrc"), "ignore-scripts=true\nomit[]=dev\nomit[]=optional\n")
29+
30+
const config = await Effect.runPromise(NpmConfig.load(tmp.path))
31+
32+
expect(config.ignoreScripts).toBe(true)
33+
expect(config.omit).toEqual(["dev", "optional"])
34+
})
35+
})
36+
37+
describe("NpmConfig.registry", () => {
38+
test("normalizes configured registry without trailing slash", async () => {
39+
await using tmp = await tmpdir()
40+
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
41+
42+
await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
43+
})
44+
45+
test("leaves configured registry without trailing slash unchanged", async () => {
46+
await using tmp = await tmpdir()
47+
await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test\n")
48+
49+
await expect(Effect.runPromise(NpmConfig.registry(tmp.path))).resolves.toBe("https://registry.example.test")
50+
})
51+
})

packages/core/test/npm.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import fs from "fs/promises"
2+
import path from "path"
3+
import { describe, expect, test } from "bun:test"
4+
import { Npm } from "@opencode-ai/core/npm"
5+
import { tmpdir } from "./fixture/tmpdir"
6+
7+
const win = process.platform === "win32"
8+
9+
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
10+
Bun.write(
11+
path.join(dir, "package.json"),
12+
JSON.stringify({
13+
version: "1.0.0",
14+
...pkg,
15+
}),
16+
)
17+
18+
describe("Npm.sanitize", () => {
19+
test("keeps normal scoped package specs unchanged", () => {
20+
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
21+
expect(Npm.sanitize("@opencode/[email protected]")).toBe("@opencode/[email protected]")
22+
expect(Npm.sanitize("prettier")).toBe("prettier")
23+
})
24+
25+
test("handles git https specs", () => {
26+
const spec = "acme@git+https://github.com/opencode/acme.git"
27+
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
28+
expect(Npm.sanitize(spec)).toBe(expected)
29+
})
30+
})
31+
32+
describe("Npm.install", () => {
33+
test("respects omit from project .npmrc", async () => {
34+
await using tmp = await tmpdir()
35+
36+
await writePackage(tmp.path, {
37+
name: "fixture",
38+
dependencies: {
39+
"prod-pkg": "file:./prod-pkg",
40+
},
41+
devDependencies: {
42+
"dev-pkg": "file:./dev-pkg",
43+
},
44+
})
45+
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
46+
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
47+
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
48+
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
49+
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
50+
51+
await Npm.install(tmp.path)
52+
53+
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
54+
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
55+
})
56+
})

packages/opencode/src/cli/cmd/uninstall.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Argv } from "yargs"
22
import { UI } from "../ui"
33
import * as prompts from "@clack/prompts"
4-
import { AppRuntime } from "@/effect/app-runtime"
54
import { Installation } from "../../installation"
65
import { Global } from "@opencode-ai/core/global"
76
import fs from "fs/promises"
@@ -58,7 +57,7 @@ export const UninstallCommand = {
5857
UI.empty()
5958
prompts.intro("Uninstall OpenCode")
6059

61-
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
60+
const method = await Installation.method()
6261
prompts.log.info(`Installation method: ${method}`)
6362

6463
const targets = await collectRemovalTargets(args, method)

packages/opencode/src/cli/cmd/upgrade.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Argv } from "yargs"
22
import { UI } from "../ui"
33
import * as prompts from "@clack/prompts"
4-
import { AppRuntime } from "@/effect/app-runtime"
54
import { Installation } from "../../installation"
65
import { InstallationVersion } from "@opencode-ai/core/installation/version"
76

@@ -26,7 +25,7 @@ export const UpgradeCommand = {
2625
UI.println(UI.logo(" "))
2726
UI.empty()
2827
prompts.intro("Upgrade")
29-
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
28+
const detectedMethod = await Installation.method()
3029
const method = (args.method as Installation.Method) ?? detectedMethod
3130
if (method === "unknown") {
3231
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -44,9 +43,7 @@ export const UpgradeCommand = {
4443
}
4544
}
4645
prompts.log.info("Using method: " + method)
47-
const target = args.target
48-
? args.target.replace(/^v/, "")
49-
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
46+
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
5047

5148
if (InstallationVersion === target) {
5249
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -57,9 +54,7 @@ export const UpgradeCommand = {
5754
prompts.log.info(`From ${InstallationVersion}${target}`)
5855
const spinner = prompts.spinner()
5956
spinner.start("Upgrading...")
60-
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
61-
(err) => err,
62-
)
57+
const err = await Installation.upgrade(method, target).catch((err) => err)
6358
if (err) {
6459
spinner.stop("Upgrade failed", 1)
6560
if (err instanceof Installation.UpgradeFailedError) {

0 commit comments

Comments
 (0)