@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
@@ -0,0 +1,107 @@
1
+ import { Effect, Layer, Logger } from "effect"
2
+ import { FetchHttpClient } from "effect/unstable/http"
3
+ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
4
+ import * as EffectLogger from "./logger"
5
+ import { Flag } from "../flag/flag"
6
+ import { InstallationChannel, InstallationVersion } from "../installation/version"
7
+ import { ensureProcessMetadata } from "../util/saeeol-process"
8
+
9
+ const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
10
+ export const enabled = !!base
11
+ const processID = crypto.randomUUID()
12
+
13
+ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
14
+ ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
15
+ (acc, x) => {
16
+ const [key, ...value] = x.split("=")
17
+ acc[key] = value.join("=")
18
+ return acc
19
+ },
20
+ {} as Record<string, string>,
21
+ )
22
+ : undefined
23
+
24
+ export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
25
+ const processMetadata = ensureProcessMetadata("main")
26
+ const attributes: Record<string, string> = (() => {
27
+ const value = process.env.OTEL_RESOURCE_ATTRIBUTES
28
+ if (!value) return {}
29
+ try {
30
+ return Object.fromEntries(
31
+ value.split(",").map((entry) => {
32
+ const index = entry.indexOf("=")
33
+ if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
34
+ return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
35
+ }),
36
+ )
37
+ } catch {
38
+ return {}
39
+ }
40
+ })()
41
+
42
+ return {
43
+ serviceName: "saeeol",
44
+ serviceVersion: InstallationVersion,
45
+ attributes: {
46
+ ...attributes,
47
+ "deployment.environment.name": InstallationChannel,
48
+ "saeeol.client": Flag.SAEEOL_CLIENT,
49
+ "saeeol.process_role": processMetadata.processRole,
50
+ "saeeol.run_id": processMetadata.runID,
51
+ "service.instance.id": processID,
52
+ },
53
+ }
54
+ }
55
+
56
+ function logs() {
57
+ return Logger.layer(
58
+ [
59
+ EffectLogger.logger,
60
+ OtlpLogger.make({
61
+ url: `${base}/v1/logs`,
62
+ resource: resource(),
63
+ headers,
64
+ }),
65
+ ],
66
+ { mergeWithExisting: false },
67
+ ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
68
+ }
69
+
70
+ const traces = async () => {
71
+ const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
72
+ const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
73
+ const SdkBase = await import("@opentelemetry/sdk-trace-base")
74
+
75
+ // @effect/opentelemetry creates a NodeTracerProvider but never calls
76
+ // register(), so the global @opentelemetry/api context manager stays
77
+ // as the no-op default. Non-Effect code (like the AI SDK) that calls
78
+ // tracer.startActiveSpan() relies on context.active() to find the
79
+ // parent span - without a real context manager every span starts a
80
+ // new trace. Registering AsyncLocalStorageContextManager fixes this.
81
+ const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
82
+ const { context } = await import("@opentelemetry/api")
83
+ const mgr = new AsyncLocalStorageContextManager()
84
+ mgr.enable()
85
+ context.setGlobalContextManager(mgr)
86
+
87
+ return NodeSdk.layer(() => ({
88
+ resource: resource(),
89
+ spanProcessor: new SdkBase.BatchSpanProcessor(
90
+ new OTLP.OTLPTraceExporter({
91
+ url: `${base}/v1/traces`,
92
+ headers,
93
+ }),
94
+ ),
95
+ }))
96
+ }
97
+
98
+ export const layer = !base
99
+ ? EffectLogger.layer
100
+ : Layer.unwrap(
101
+ Effect.gen(function* () {
102
+ const trace = yield* Effect.promise(traces)
103
+ return Layer.mergeAll(trace, logs())
104
+ }),
105
+ )
106
+
107
+ export const Observability = { enabled, layer }
@@ -0,0 +1,21 @@
1
+ import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
2
+ import { memoMap } from "./memo-map"
3
+ import { Observability } from "./observability"
4
+
5
+ export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
6
+ let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
7
+ const getRuntime = () =>
8
+ (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, {
9
+ memoMap,
10
+ }))
11
+
12
+ return {
13
+ runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
14
+ runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
15
+ getRuntime().runPromiseExit(service.use(fn), options),
16
+ runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
17
+ getRuntime().runPromise(service.use(fn), options),
18
+ runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
19
+ runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
20
+ }
21
+ }
@@ -0,0 +1,262 @@
1
+ import { NodeFileSystem } from "@effect/platform-node"
2
+ import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
3
+ import { realpathSync } from "fs"
4
+ import * as NFS from "fs/promises"
5
+ import { lookup } from "mime-types"
6
+ import { Effect, FileSystem, Layer, Schema, Context } from "effect"
7
+ import type { PlatformError } from "effect/PlatformError"
8
+ import { Glob } from "./util/glob"
9
+ // fs.mkdir(dir, { recursive: true }) should be idempotent, but on Windows
10
+ // with NTFS reparse points (OneDrive), directory junctions, or WSL-served
11
+ // paths, libuv can still throw EEXIST. This wrapper catches that specific
12
+ // error so callers get the promised directory-exists semantics.
13
+ //
14
+ // https://github.com/Saeeol-Org/saeeol/issues/9618
15
+ // https://github.com/Saeeol-Org/saeeol/issues/9755
16
+ function isEexist(err: unknown): boolean {
17
+ return typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "EEXIST"
18
+ }
19
+
20
+ async function mkdirSafe(dir: string): Promise<void> {
21
+ try {
22
+ await NFS.mkdir(dir, { recursive: true })
23
+ } catch (err: unknown) {
24
+ if (isEexist(err)) return
25
+ throw err
26
+ }
27
+ }
28
+
29
+ export namespace AppFileSystem {
30
+ export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
31
+ method: Schema.String,
32
+ cause: Schema.optional(Schema.Defect),
33
+ }) {}
34
+
35
+ export type Error = PlatformError | FileSystemError
36
+
37
+ export interface DirEntry {
38
+ readonly name: string
39
+ readonly type: "file" | "directory" | "symlink" | "other"
40
+ }
41
+
42
+ export interface Interface extends FileSystem.FileSystem {
43
+ readonly isDir: (path: string) => Effect.Effect<boolean>
44
+ readonly isFile: (path: string) => Effect.Effect<boolean>
45
+ readonly existsSafe: (path: string) => Effect.Effect<boolean>
46
+ readonly readJson: (path: string) => Effect.Effect<unknown, Error>
47
+ readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
48
+ readonly ensureDir: (path: string) => Effect.Effect<void, Error>
49
+ readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
50
+ readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
51
+ readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
52
+ readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
53
+ readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
54
+ readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
55
+ readonly globMatch: (pattern: string, filepath: string) => boolean
56
+ }
57
+
58
+ export class Service extends Context.Service<Service, Interface>()("@saeeol/FileSystem") {}
59
+
60
+ export const layer = Layer.effect(
61
+ Service,
62
+ Effect.gen(function* () {
63
+ const fs = yield* FileSystem.FileSystem
64
+
65
+ const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
66
+ return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
67
+ })
68
+
69
+ const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
70
+ const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
71
+ return info?.type === "Directory"
72
+ })
73
+
74
+ const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
75
+ const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
76
+ return info?.type === "File"
77
+ })
78
+
79
+ const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
80
+ return yield* Effect.tryPromise({
81
+ try: async () => {
82
+ const entries = await NFS.readdir(dirPath, { withFileTypes: true })
83
+ return entries.map(
84
+ (e): DirEntry => ({
85
+ name: e.name,
86
+ type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
87
+ }),
88
+ )
89
+ },
90
+ catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
91
+ })
92
+ })
93
+
94
+ const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
95
+ const text = yield* fs.readFileString(path)
96
+ return JSON.parse(text)
97
+ })
98
+
99
+ const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
100
+ const content = JSON.stringify(data, null, 2)
101
+ yield* fs.writeFileString(path, content)
102
+ if (mode) yield* fs.chmod(path, mode)
103
+ })
104
+
105
+ const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
106
+ yield* Effect.tryPromise({
107
+ try: () => mkdirSafe(path),
108
+ catch: (cause) => new FileSystemError({ method: "ensureDir", cause }),
109
+ })
110
+ })
111
+
112
+ const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
113
+ path: string,
114
+ content: string | Uint8Array,
115
+ mode?: number,
116
+ ) {
117
+ const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
118
+
119
+ yield* write.pipe(
120
+ Effect.catchIf(
121
+ (e) => e.reason._tag === "NotFound",
122
+ () =>
123
+ Effect.gen(function* () {
124
+ yield* Effect.tryPromise({
125
+ try: () => mkdirSafe(dirname(path)),
126
+ catch: (cause) => new FileSystemError({ method: "writeWithDirs:mkdir", cause }),
127
+ })
128
+ yield* write
129
+ }),
130
+ ),
131
+ )
132
+ if (mode) yield* fs.chmod(path, mode)
133
+ })
134
+
135
+ const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
136
+ return yield* Effect.tryPromise({
137
+ try: () => Glob.scan(pattern, options),
138
+ catch: (cause) => new FileSystemError({ method: "glob", cause }),
139
+ })
140
+ })
141
+
142
+ const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
143
+ const result: string[] = []
144
+ let current = start
145
+ while (true) {
146
+ const search = join(current, target)
147
+ if (yield* fs.exists(search)) result.push(search)
148
+ if (stop === current) break
149
+ const parent = dirname(current)
150
+ if (parent === current) break
151
+ current = parent
152
+ }
153
+ return result
154
+ })
155
+
156
+ const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
157
+ const result: string[] = []
158
+ let current = options.start
159
+ while (true) {
160
+ for (const target of options.targets) {
161
+ const search = join(current, target)
162
+ if (yield* fs.exists(search)) result.push(search)
163
+ }
164
+ if (options.stop === current) break
165
+ const parent = dirname(current)
166
+ if (parent === current) break
167
+ current = parent
168
+ }
169
+ return result
170
+ })
171
+
172
+ const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
173
+ const result: string[] = []
174
+ let current = start
175
+ while (true) {
176
+ const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
177
+ Effect.catch(() => Effect.succeed([] as string[])),
178
+ )
179
+ result.push(...matches)
180
+ if (stop === current) break
181
+ const parent = dirname(current)
182
+ if (parent === current) break
183
+ current = parent
184
+ }
185
+ return result
186
+ })
187
+
188
+ return Service.of({
189
+ ...fs,
190
+ existsSafe,
191
+ isDir,
192
+ isFile,
193
+ readDirectoryEntries,
194
+ readJson,
195
+ writeJson,
196
+ ensureDir,
197
+ writeWithDirs,
198
+ findUp,
199
+ up,
200
+ globUp,
201
+ glob,
202
+ globMatch: Glob.match,
203
+ })
204
+ }),
205
+ )
206
+
207
+ export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
208
+
209
+ // Pure helpers that don't need Effect (path manipulation, sync operations)
210
+ export function mimeType(p: string): string {
211
+ return lookup(p) || "application/octet-stream"
212
+ }
213
+
214
+ export function normalizePath(p: string): string {
215
+ if (process.platform !== "win32") return p
216
+ const resolved = pathResolve(windowsPath(p))
217
+ try {
218
+ return realpathSync.native(resolved)
219
+ } catch {
220
+ return resolved
221
+ }
222
+ }
223
+
224
+ export function normalizePathPattern(p: string): string {
225
+ if (process.platform !== "win32") return p
226
+ if (p === "*") return p
227
+ const match = p.match(/^(.*)[\\/]\*$/)
228
+ if (!match) return normalizePath(p)
229
+ const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
230
+ return join(normalizePath(dir), "*")
231
+ }
232
+
233
+ export function resolve(p: string): string {
234
+ const resolved = pathResolve(windowsPath(p))
235
+ try {
236
+ return normalizePath(realpathSync(resolved))
237
+ } catch (e: any) {
238
+ if (e?.code === "ENOENT") return normalizePath(resolved)
239
+ throw e
240
+ }
241
+ }
242
+
243
+ export function windowsPath(p: string): string {
244
+ if (process.platform !== "win32") return p
245
+ return p
246
+ .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
247
+ .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
248
+ .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
249
+ .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
250
+ }
251
+
252
+ export function overlaps(a: string, b: string) {
253
+ const relA = relative(a, b)
254
+ const relB = relative(b, a)
255
+ return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
256
+ }
257
+
258
+ export function contains(parent: string, child: string) {
259
+ const rel = relative(parent, child)
260
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`))
261
+ }
262
+ }
@@ -0,0 +1,107 @@
1
+ import { Config } from "effect"
2
+
3
+ function truthy(key: string) {
4
+ const value = process.env[key]?.toLowerCase()
5
+ return value === "true" || value === "1"
6
+ }
7
+
8
+ function falsy(key: string) {
9
+ const value = process.env[key]?.toLowerCase()
10
+ return value === "false" || value === "0"
11
+ }
12
+
13
+ function number(key: string) {
14
+ const value = process.env[key]
15
+ if (!value) return undefined
16
+ const parsed = Number(value)
17
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
18
+ }
19
+
20
+ const SAEEOL_EXPERIMENTAL = truthy("SAEEOL_EXPERIMENTAL")
21
+ const SAEEOL_DISABLE_CLAUDE_CODE = truthy("SAEEOL_DISABLE_CLAUDE_CODE")
22
+ const SAEEOL_DISABLE_CLAUDE_CODE_SKILLS = SAEEOL_DISABLE_CLAUDE_CODE || truthy("SAEEOL_DISABLE_CLAUDE_CODE_SKILLS")
23
+ const copy = process.env["SAEEOL_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
24
+
25
+ export const Flag = {
26
+ OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
27
+ OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
28
+
29
+ SAEEOL_AUTO_SHARE: truthy("SAEEOL_AUTO_SHARE"),
30
+ SAEEOL_AUTO_HEAP_SNAPSHOT: truthy("SAEEOL_AUTO_HEAP_SNAPSHOT"),
31
+ SAEEOL_GIT_BASH_PATH: process.env["SAEEOL_GIT_BASH_PATH"],
32
+ SAEEOL_CONFIG: process.env["SAEEOL_CONFIG"],
33
+ SAEEOL_CONFIG_CONTENT: process.env["SAEEOL_CONFIG_CONTENT"],
34
+ SAEEOL_DISABLE_AUTOUPDATE: truthy("SAEEOL_DISABLE_AUTOUPDATE"),
35
+ SAEEOL_ALWAYS_NOTIFY_UPDATE: truthy("SAEEOL_ALWAYS_NOTIFY_UPDATE"),
36
+ SAEEOL_DISABLE_PRUNE: truthy("SAEEOL_DISABLE_PRUNE"),
37
+ SAEEOL_DISABLE_TERMINAL_TITLE: truthy("SAEEOL_DISABLE_TERMINAL_TITLE"),
38
+ SAEEOL_SHOW_TTFD: truthy("SAEEOL_SHOW_TTFD"),
39
+ SAEEOL_PERMISSION: process.env["SAEEOL_PERMISSION"],
40
+ SAEEOL_DISABLE_DEFAULT_PLUGINS: truthy("SAEEOL_DISABLE_DEFAULT_PLUGINS"),
41
+ SAEEOL_DISABLE_LSP_DOWNLOAD: truthy("SAEEOL_DISABLE_LSP_DOWNLOAD"),
42
+ SAEEOL_ENABLE_EXPERIMENTAL_MODELS: truthy("SAEEOL_ENABLE_EXPERIMENTAL_MODELS"),
43
+ SAEEOL_DISABLE_AUTOCOMPACT: truthy("SAEEOL_DISABLE_AUTOCOMPACT"),
44
+ SAEEOL_DISABLE_MODELS_FETCH: truthy("SAEEOL_DISABLE_MODELS_FETCH"),
45
+ SAEEOL_DISABLE_MOUSE: truthy("SAEEOL_DISABLE_MOUSE"),
46
+ SAEEOL_DISABLE_CLAUDE_CODE,
47
+ SAEEOL_DISABLE_CLAUDE_CODE_PROMPT: SAEEOL_DISABLE_CLAUDE_CODE || truthy("SAEEOL_DISABLE_CLAUDE_CODE_PROMPT"),
48
+ SAEEOL_DISABLE_CLAUDE_CODE_SKILLS,
49
+ SAEEOL_DISABLE_EXTERNAL_SKILLS: SAEEOL_DISABLE_CLAUDE_CODE_SKILLS || truthy("SAEEOL_DISABLE_EXTERNAL_SKILLS"),
50
+ SAEEOL_FAKE_VCS: process.env["SAEEOL_FAKE_VCS"],
51
+ SAEEOL_SERVER_PASSWORD: process.env["SAEEOL_SERVER_PASSWORD"],
52
+ SAEEOL_SERVER_USERNAME: process.env["SAEEOL_SERVER_USERNAME"],
53
+ SAEEOL_ENABLE_QUESTION_TOOL: truthy("SAEEOL_ENABLE_QUESTION_TOOL"),
54
+
55
+ // Experimental
56
+ SAEEOL_EXPERIMENTAL,
57
+ SAEEOL_EXPERIMENTAL_FILEWATCHER: Config.boolean("SAEEOL_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)),
58
+ SAEEOL_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("SAEEOL_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
59
+ Config.withDefault(false),
60
+ ),
61
+ SAEEOL_EXPERIMENTAL_ICON_DISCOVERY: SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_ICON_DISCOVERY"),
62
+ SAEEOL_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
63
+ copy === undefined ? process.platform === "win32" : truthy("SAEEOL_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
64
+ SAEEOL_ENABLE_EXA: truthy("SAEEOL_ENABLE_EXA") || SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_EXA"),
65
+ SAEEOL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("SAEEOL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
66
+ SAEEOL_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("SAEEOL_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
67
+ SAEEOL_EXPERIMENTAL_OXFMT: SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_OXFMT"),
68
+ SAEEOL_EXPERIMENTAL_LSP_TY: truthy("SAEEOL_EXPERIMENTAL_LSP_TY"),
69
+ SAEEOL_EXPERIMENTAL_LSP_TOOL: SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_LSP_TOOL"),
70
+ SAEEOL_EXPERIMENTAL_PLAN_MODE: SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_PLAN_MODE"),
71
+ SAEEOL_EXPERIMENTAL_MARKDOWN: !falsy("SAEEOL_EXPERIMENTAL_MARKDOWN"),
72
+ SAEEOL_MODELS_URL: process.env["SAEEOL_MODELS_URL"],
73
+ SAEEOL_MODELS_PATH: process.env["SAEEOL_MODELS_PATH"],
74
+ SAEEOL_DISABLE_EMBEDDED_WEB_UI: truthy("SAEEOL_DISABLE_EMBEDDED_WEB_UI"),
75
+ SAEEOL_DB: process.env["SAEEOL_DB"],
76
+ SAEEOL_DISABLE_CHANNEL_DB: truthy("SAEEOL_DISABLE_CHANNEL_DB"),
77
+ SAEEOL_SKIP_MIGRATIONS: truthy("SAEEOL_SKIP_MIGRATIONS"),
78
+ SAEEOL_STRICT_CONFIG_DEPS: truthy("SAEEOL_STRICT_CONFIG_DEPS"),
79
+
80
+ SAEEOL_WORKSPACE_ID: process.env["SAEEOL_WORKSPACE_ID"],
81
+ SAEEOL_EXPERIMENTAL_HTTPAPI: truthy("SAEEOL_EXPERIMENTAL_HTTPAPI"),
82
+ SAEEOL_EXPERIMENTAL_WORKSPACES: SAEEOL_EXPERIMENTAL || truthy("SAEEOL_EXPERIMENTAL_WORKSPACES"),
83
+
84
+ // Evaluated at access time (not module load) because tests, the CLI, and
85
+ // external tooling set these env vars at runtime.
86
+ get SAEEOL_DISABLE_PROJECT_CONFIG() {
87
+ return truthy("SAEEOL_DISABLE_PROJECT_CONFIG")
88
+ },
89
+ get SAEEOL_TUI_CONFIG() {
90
+ return process.env["SAEEOL_TUI_CONFIG"]
91
+ },
92
+ get SAEEOL_CONFIG_DIR() {
93
+ return process.env["SAEEOL_CONFIG_DIR"]
94
+ },
95
+ get SAEEOL_PURE() {
96
+ return truthy("SAEEOL_PURE")
97
+ },
98
+ get SAEEOL_PLUGIN_META_FILE() {
99
+ return process.env["SAEEOL_PLUGIN_META_FILE"]
100
+ },
101
+ get SAEEOL_CLIENT() {
102
+ return process.env["SAEEOL_CLIENT"] ?? "cli"
103
+ },
104
+ get SAEEOL_SESSION_RETRY_LIMIT() {
105
+ return number("SAEEOL_SESSION_RETRY_LIMIT")
106
+ },
107
+ }
package/src/global.ts ADDED
@@ -0,0 +1,91 @@
1
+ import path from "path"
2
+ import fs from "fs/promises"
3
+ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
4
+ import os from "os"
5
+ import { Context, Effect, Layer } from "effect"
6
+ import { Flock } from "./util/flock"
7
+ import { markNoIndex } from "./saeeol/spotlight"
8
+ import { ensureRealDir } from "./saeeol/global"
9
+ import { Flag } from "./flag/flag"
10
+
11
+ const app = "saeeol"
12
+ // Defensively strip newline characters from the resolved XDG paths.
13
+ // If `$HOME` (or any `$XDG_*_HOME` override) has a trailing newline in
14
+ // the user's shell — e.g. because a shell snippet did `export HOME=$(cmd)`
15
+ // against a command with an implicit newline — the unsanitised path
16
+ // makes `fs.mkdir` try to create `/Users/<name>\n` and fail with EACCES,
17
+ // which breaks every `saeeol` invocation at startup (including the SDK
18
+ // regen that runs during `bun run extension`).
19
+ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "")
20
+ const data = path.join(clean(xdgData)!, app)
21
+ const cache = path.join(clean(xdgCache)!, app)
22
+ const config = path.join(clean(xdgConfig)!, app)
23
+ const state = path.join(clean(xdgState)!, app)
24
+ const tmp = path.join(os.tmpdir(), app)
25
+
26
+ const paths = {
27
+ get home() {
28
+ return (process.env.SAEEOL_TEST_HOME ?? os.homedir()).trim()
29
+ },
30
+ data,
31
+ bin: path.join(cache, "bin"),
32
+ log: path.join(data, "log"),
33
+ cache,
34
+ config,
35
+ state,
36
+ tmp,
37
+ }
38
+
39
+ export const Path = paths
40
+
41
+ Flock.setGlobal({ state })
42
+
43
+ await Promise.all([
44
+ ensureRealDir(Path.data),
45
+ ensureRealDir(Path.config),
46
+ ensureRealDir(Path.state),
47
+ ensureRealDir(Path.tmp),
48
+ ensureRealDir(Path.log),
49
+ ensureRealDir(Path.bin),
50
+ ])
51
+ await Promise.all([Path.data, Path.cache, Path.state].map(markNoIndex))
52
+
53
+ export class Service extends Context.Service<Service, Interface>()("@saeeol/Global") {}
54
+
55
+ export interface Interface {
56
+ readonly home: string
57
+ readonly data: string
58
+ readonly cache: string
59
+ readonly config: string
60
+ readonly state: string
61
+ readonly tmp: string
62
+ readonly bin: string
63
+ readonly log: string
64
+ }
65
+
66
+ export function make(input: Partial<Interface> = {}): Interface {
67
+ return {
68
+ home: Path.home,
69
+ data: Path.data,
70
+ cache: Path.cache,
71
+ config: Flag.SAEEOL_CONFIG_DIR ?? Path.config,
72
+ state: Path.state,
73
+ tmp: Path.tmp,
74
+ bin: Path.bin,
75
+ log: Path.log,
76
+ ...input,
77
+ }
78
+ }
79
+
80
+ export const layer = Layer.effect(
81
+ Service,
82
+ Effect.sync(() => Service.of(make())),
83
+ )
84
+
85
+ export const layerWith = (input: Partial<Interface>) =>
86
+ Layer.effect(
87
+ Service,
88
+ Effect.sync(() => Service.of(make(input))),
89
+ )
90
+
91
+ export * as Global from "./global"
@@ -0,0 +1,11 @@
1
+ declare global {
2
+ const SAEEOL_VERSION: string
3
+ const SAEEOL_CHANNEL: string
4
+ const SAEEOL_BUILD_KIND: string
5
+ }
6
+
7
+ export const InstallationVersion = typeof SAEEOL_VERSION === "string" ? SAEEOL_VERSION : "local"
8
+ export const InstallationChannel = typeof SAEEOL_CHANNEL === "string" ? SAEEOL_CHANNEL : "local"
9
+ export const InstallationLocal = InstallationChannel === "local"
10
+ export const InstallationBuildKind: "source" | "release" =
11
+ typeof SAEEOL_BUILD_KIND === "string" && SAEEOL_BUILD_KIND === "release" ? "release" : "source"
@@ -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
+ )