@saeeol/core 7.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +52 -0
  2. package/src/cross-spawn-process.ts +273 -0
  3. package/src/cross-spawn-spawner.ts +505 -0
  4. package/src/cross-spawn-utils.ts +74 -0
  5. package/src/effect/logger.ts +73 -0
  6. package/src/effect/memo-map.ts +3 -0
  7. package/src/effect/observability.ts +107 -0
  8. package/src/effect/runtime.ts +21 -0
  9. package/src/filesystem.ts +262 -0
  10. package/src/flag/flag.ts +107 -0
  11. package/src/global.ts +91 -0
  12. package/src/installation/version.ts +11 -0
  13. package/src/npm-config.ts +40 -0
  14. package/src/npm.ts +271 -0
  15. package/src/saeeol/global.ts +23 -0
  16. package/src/saeeol/kilocode/global.ts +23 -0
  17. package/src/saeeol/kilocode/spotlight.ts +23 -0
  18. package/src/saeeol/spotlight.ts +23 -0
  19. package/src/util/array.ts +10 -0
  20. package/src/util/binary.ts +41 -0
  21. package/src/util/effect-flock.ts +283 -0
  22. package/src/util/encode.ts +52 -0
  23. package/src/util/error.ts +60 -0
  24. package/src/util/flock.ts +358 -0
  25. package/src/util/glob.ts +34 -0
  26. package/src/util/hash.ts +7 -0
  27. package/src/util/identifier.ts +48 -0
  28. package/src/util/iife.ts +3 -0
  29. package/src/util/lazy.ts +11 -0
  30. package/src/util/log.ts +208 -0
  31. package/src/util/module.ts +10 -0
  32. package/src/util/path.ts +37 -0
  33. package/src/util/retry.ts +42 -0
  34. package/src/util/saeeol-process.ts +24 -0
  35. package/src/util/slug.ts +74 -0
  36. package/sst-env.d.ts +10 -0
  37. package/test/effect/cross-spawn-spawner.test.ts +423 -0
  38. package/test/effect/observability.test.ts +46 -0
  39. package/test/filesystem/filesystem.test.ts +338 -0
  40. package/test/fixture/effect-flock-worker.ts +60 -0
  41. package/test/fixture/flock-worker.ts +72 -0
  42. package/test/fixture/tmpdir.ts +13 -0
  43. package/test/global.test.ts +16 -0
  44. package/test/lib/effect.ts +53 -0
  45. package/test/npm-config.test.ts +51 -0
  46. package/test/npm.test.ts +91 -0
  47. package/test/saeeol/filesystem-containment.test.ts +13 -0
  48. package/test/saeeol/kilocode/filesystem-containment.test.ts +13 -0
  49. package/test/util/effect-flock.test.ts +386 -0
  50. package/test/util/flock.test.ts +426 -0
  51. package/tsconfig.json +8 -0
package/src/npm.ts ADDED
@@ -0,0 +1,271 @@
1
+ export * as Npm from "./npm"
2
+
3
+ import path from "path"
4
+ import npa from "npm-package-arg"
5
+ import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
6
+ import { NodeFileSystem } from "@effect/platform-node"
7
+ import { AppFileSystem } from "./filesystem"
8
+ import { Global } from "./global"
9
+ import { EffectFlock } from "./util/effect-flock"
10
+ import { makeRuntime } from "./effect/runtime"
11
+ import { NpmConfig } from "./npm-config"
12
+
13
+ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
14
+ add: Schema.Array(Schema.String).pipe(Schema.optional),
15
+ dir: Schema.String,
16
+ cause: Schema.optional(Schema.Defect),
17
+ }) {}
18
+
19
+ export interface EntryPoint {
20
+ readonly directory: string
21
+ readonly entrypoint: Option.Option<string>
22
+ }
23
+
24
+ export interface Interface {
25
+ readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
26
+ readonly install: (
27
+ dir: string,
28
+ input?: {
29
+ add: {
30
+ name: string
31
+ version?: string
32
+ }[]
33
+ },
34
+ ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
35
+ readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
36
+ }
37
+
38
+ export class Service extends Context.Service<Service, Interface>()("@saeeol/Npm") {}
39
+
40
+ const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
41
+
42
+ export function sanitize(pkg: string) {
43
+ if (!illegal) return pkg
44
+ return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
45
+ }
46
+
47
+ const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
48
+ let entrypoint: Option.Option<string>
49
+ try {
50
+ const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
51
+ entrypoint = Option.some(resolved)
52
+ } catch {
53
+ entrypoint = Option.none()
54
+ }
55
+ return {
56
+ directory: dir,
57
+ entrypoint,
58
+ }
59
+ }
60
+
61
+ interface ArboristNode {
62
+ name: string
63
+ path: string
64
+ }
65
+
66
+ interface ArboristTree {
67
+ edgesOut: Map<string, { to?: ArboristNode }>
68
+ }
69
+
70
+ export const layer = Layer.effect(
71
+ Service,
72
+ Effect.gen(function* () {
73
+ const afs = yield* AppFileSystem.Service
74
+ const global = yield* Global.Service
75
+ const fs = yield* FileSystem.FileSystem
76
+ const flock = yield* EffectFlock.Service
77
+ const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
78
+ const reify = (input: { dir: string; add?: string[] }) =>
79
+ Effect.gen(function* () {
80
+ yield* flock.acquire(`npm-install:${input.dir}`)
81
+ const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
82
+ const add = input.add ?? []
83
+ const npmOptions = yield* NpmConfig.load(input.dir)
84
+ const arborist = new Arborist({
85
+ ...npmOptions,
86
+ path: input.dir,
87
+ binLinks: true,
88
+ progress: false,
89
+ savePrefix: "",
90
+ ignoreScripts: true,
91
+ })
92
+ return yield* Effect.tryPromise({
93
+ try: () =>
94
+ arborist.reify({
95
+ ...npmOptions,
96
+ add,
97
+ save: true,
98
+ saveType: "prod",
99
+ }),
100
+ catch: (cause) =>
101
+ new InstallFailedError({
102
+ cause,
103
+ add,
104
+ dir: input.dir,
105
+ }),
106
+ }) as Effect.Effect<ArboristTree, InstallFailedError>
107
+ }).pipe(
108
+ Effect.withSpan("Npm.reify", {
109
+ attributes: input,
110
+ }),
111
+ )
112
+
113
+ const add = Effect.fn("Npm.add")(function* (pkg: string) {
114
+ const dir = directory(pkg)
115
+ const name = (() => {
116
+ try {
117
+ return npa(pkg).name ?? pkg
118
+ } catch {
119
+ return pkg
120
+ }
121
+ })()
122
+
123
+ if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
124
+ return resolveEntryPoint(name, path.join(dir, "node_modules", name))
125
+ }
126
+
127
+ const tree = yield* reify({ dir, add: [pkg] })
128
+ const first = tree.edgesOut.values().next().value?.to
129
+ if (!first) {
130
+ const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
131
+ if (Option.isSome(result.entrypoint)) return result
132
+ return yield* new InstallFailedError({ add: [pkg], dir })
133
+ }
134
+ return resolveEntryPoint(first.name, first.path)
135
+ }, Effect.scoped)
136
+
137
+ const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
138
+ const canWrite = yield* afs.access(dir, { writable: true }).pipe(
139
+ Effect.as(true),
140
+ Effect.orElseSucceed(() => false),
141
+ )
142
+ if (!canWrite) return
143
+
144
+ const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
145
+ if (
146
+ yield* Effect.gen(function* () {
147
+ const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
148
+ if (!nodeModulesExists) {
149
+ yield* reify({ add, dir })
150
+ return true
151
+ }
152
+ return false
153
+ }).pipe(Effect.withSpan("Npm.checkNodeModules"))
154
+ )
155
+ return
156
+
157
+ yield* Effect.gen(function* () {
158
+ const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
159
+ const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
160
+
161
+ const pkgAny = pkg as any
162
+ const lockAny = lock as any
163
+ const declared = new Set([
164
+ ...Object.keys(pkgAny?.dependencies || {}),
165
+ ...Object.keys(pkgAny?.devDependencies || {}),
166
+ ...Object.keys(pkgAny?.peerDependencies || {}),
167
+ ...Object.keys(pkgAny?.optionalDependencies || {}),
168
+ ...(input?.add || []).map((pkg) => pkg.name),
169
+ ])
170
+
171
+ const root = lockAny?.packages?.[""] || {}
172
+ const locked = new Set([
173
+ ...Object.keys(root?.dependencies || {}),
174
+ ...Object.keys(root?.devDependencies || {}),
175
+ ...Object.keys(root?.peerDependencies || {}),
176
+ ...Object.keys(root?.optionalDependencies || {}),
177
+ ])
178
+
179
+ for (const name of declared) {
180
+ if (!locked.has(name)) {
181
+ yield* reify({ dir, add })
182
+ return
183
+ }
184
+ }
185
+ }).pipe(Effect.withSpan("Npm.checkDirty"))
186
+
187
+ return
188
+ }, Effect.scoped)
189
+
190
+ const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
191
+ const dir = directory(pkg)
192
+ const binDir = path.join(dir, "node_modules", ".bin")
193
+
194
+ const pick = Effect.fnUntraced(function* () {
195
+ const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
196
+
197
+ if (files.length === 0) return Option.none<string>()
198
+ // Caller picked a specific bin (e.g. pyright exposes both `pyright` and
199
+ // `pyright-langserver`); trust the hint if the package provides it.
200
+ if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
201
+ if (files.length === 1) return Option.some(files[0])
202
+
203
+ const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
204
+
205
+ if (Option.isSome(pkgJson)) {
206
+ const parsed = pkgJson.value as { bin?: string | Record<string, string> }
207
+ if (parsed?.bin) {
208
+ const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
209
+ const parsedBin = parsed.bin
210
+ if (typeof parsedBin === "string") return Option.some(unscoped)
211
+ const keys = Object.keys(parsedBin)
212
+ if (keys.length === 1) return Option.some(keys[0])
213
+ return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
214
+ }
215
+ }
216
+
217
+ return Option.some(files[0])
218
+ })
219
+
220
+ return yield* Effect.gen(function* () {
221
+ const bin = yield* pick()
222
+ if (Option.isSome(bin)) {
223
+ return Option.some(path.join(binDir, bin.value))
224
+ }
225
+
226
+ yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
227
+
228
+ yield* add(pkg)
229
+
230
+ const resolved = yield* pick()
231
+ if (Option.isNone(resolved)) return Option.none<string>()
232
+ return Option.some(path.join(binDir, resolved.value))
233
+ }).pipe(
234
+ Effect.scoped,
235
+ Effect.orElseSucceed(() => Option.none<string>()),
236
+ )
237
+ })
238
+
239
+ return Service.of({
240
+ add,
241
+ install,
242
+ which,
243
+ })
244
+ }),
245
+ )
246
+
247
+ export const defaultLayer = layer.pipe(
248
+ Layer.provide(EffectFlock.layer),
249
+ Layer.provide(AppFileSystem.layer),
250
+ Layer.provide(Global.layer),
251
+ Layer.provide(NodeFileSystem.layer),
252
+ )
253
+
254
+ const { runPromise } = makeRuntime(Service, defaultLayer)
255
+
256
+ export async function install(...args: Parameters<Interface["install"]>) {
257
+ return runPromise((svc) => svc.install(...args))
258
+ }
259
+
260
+ export async function add(...args: Parameters<Interface["add"]>) {
261
+ const entry = await runPromise((svc) => svc.add(...args))
262
+ return {
263
+ directory: entry.directory,
264
+ entrypoint: Option.getOrUndefined(entry.entrypoint),
265
+ }
266
+ }
267
+
268
+ export async function which(...args: Parameters<Interface["which"]>) {
269
+ const resolved = await runPromise((svc) => svc.which(...args))
270
+ return Option.getOrUndefined(resolved)
271
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "fs/promises"
2
+
3
+ /**
4
+ * Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
5
+ * junctions whose target no longer exists (a Windows edge-case where the user
6
+ * had a junction at e.g. `~/.saeeol` pointing to a deleted directory).
7
+ *
8
+ * `fs.mkdir({ recursive: true })` silently no-ops when a junction exists even
9
+ * if its target is gone, so subsequent writes inside that path fail with ENOENT.
10
+ * We detect this by calling `fs.stat` (which follows the symlink/junction) after
11
+ * mkdir: if stat fails the entry is broken and we remove + recreate it.
12
+ */
13
+ export async function ensureRealDir(p: string) {
14
+ await fs.mkdir(p, { recursive: true })
15
+ const ok = await fs
16
+ .stat(p)
17
+ .then(() => true)
18
+ .catch(() => false)
19
+ if (!ok) {
20
+ await fs.rm(p, { force: true })
21
+ await fs.mkdir(p, { recursive: true })
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "fs/promises"
2
+
3
+ /**
4
+ * Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
5
+ * junctions whose target no longer exists (a Windows edge-case where the user
6
+ * had a junction at e.g. `~/.saeeol` pointing to a deleted directory).
7
+ *
8
+ * `fs.mkdir({ recursive: true })` silently no-ops when a junction exists even
9
+ * if its target is gone, so subsequent writes inside that path fail with ENOENT.
10
+ * We detect this by calling `fs.stat` (which follows the symlink/junction) after
11
+ * mkdir: if stat fails the entry is broken and we remove + recreate it.
12
+ */
13
+ export async function ensureRealDir(p: string) {
14
+ await fs.mkdir(p, { recursive: true })
15
+ const ok = await fs
16
+ .stat(p)
17
+ .then(() => true)
18
+ .catch(() => false)
19
+ if (!ok) {
20
+ await fs.rm(p, { force: true })
21
+ await fs.mkdir(p, { recursive: true })
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+
4
+ const marker = ".metadata_never_index"
5
+
6
+ function exists(err: unknown): boolean {
7
+ if (typeof err !== "object" || err === null) return false
8
+ return "code" in err && err.code === "EEXIST"
9
+ }
10
+
11
+ function message(err: unknown): string {
12
+ if (err instanceof Error) return err.message
13
+ return String(err)
14
+ }
15
+
16
+ export async function markNoIndex(dir: string): Promise<void> {
17
+ if (process.platform !== "darwin") return
18
+ const file = path.join(dir, marker)
19
+ await fs.writeFile(file, "", { flag: "wx" }).catch((err) => {
20
+ if (exists(err)) return
21
+ process.emitWarning(`Failed to mark ${dir} as Spotlight-excluded: ${message(err)}`)
22
+ })
23
+ }
@@ -0,0 +1,23 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+
4
+ const marker = ".metadata_never_index"
5
+
6
+ function exists(err: unknown): boolean {
7
+ if (typeof err !== "object" || err === null) return false
8
+ return "code" in err && err.code === "EEXIST"
9
+ }
10
+
11
+ function message(err: unknown): string {
12
+ if (err instanceof Error) return err.message
13
+ return String(err)
14
+ }
15
+
16
+ export async function markNoIndex(dir: string): Promise<void> {
17
+ if (process.platform !== "darwin") return
18
+ const file = path.join(dir, marker)
19
+ await fs.writeFile(file, "", { flag: "wx" }).catch((err) => {
20
+ if (exists(err)) return
21
+ process.emitWarning(`Failed to mark ${dir} as Spotlight-excluded: ${message(err)}`)
22
+ })
23
+ }
@@ -0,0 +1,10 @@
1
+ export function findLast<T>(
2
+ items: readonly T[],
3
+ predicate: (item: T, index: number, items: readonly T[]) => boolean,
4
+ ): T | undefined {
5
+ for (let i = items.length - 1; i >= 0; i -= 1) {
6
+ const item = items[i]
7
+ if (predicate(item, i, items)) return item
8
+ }
9
+ return undefined
10
+ }
@@ -0,0 +1,41 @@
1
+ export namespace Binary {
2
+ export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
3
+ let left = 0
4
+ let right = array.length - 1
5
+
6
+ while (left <= right) {
7
+ const mid = Math.floor((left + right) / 2)
8
+ const midId = compare(array[mid])
9
+
10
+ if (midId === id) {
11
+ return { found: true, index: mid }
12
+ } else if (midId < id) {
13
+ left = mid + 1
14
+ } else {
15
+ right = mid - 1
16
+ }
17
+ }
18
+
19
+ return { found: false, index: left }
20
+ }
21
+
22
+ export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
23
+ const id = compare(item)
24
+ let left = 0
25
+ let right = array.length
26
+
27
+ while (left < right) {
28
+ const mid = Math.floor((left + right) / 2)
29
+ const midId = compare(array[mid])
30
+
31
+ if (midId < id) {
32
+ left = mid + 1
33
+ } else {
34
+ right = mid
35
+ }
36
+ }
37
+
38
+ array.splice(left, 0, item)
39
+ return array
40
+ }
41
+ }