@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.
- package/package.json +52 -0
- package/src/cross-spawn-process.ts +273 -0
- package/src/cross-spawn-spawner.ts +505 -0
- package/src/cross-spawn-utils.ts +74 -0
- package/src/effect/logger.ts +73 -0
- package/src/effect/memo-map.ts +3 -0
- package/src/effect/observability.ts +107 -0
- package/src/effect/runtime.ts +21 -0
- package/src/filesystem.ts +262 -0
- package/src/flag/flag.ts +107 -0
- package/src/global.ts +91 -0
- package/src/installation/version.ts +11 -0
- package/src/npm-config.ts +40 -0
- package/src/npm.ts +271 -0
- package/src/saeeol/global.ts +23 -0
- package/src/saeeol/kilocode/global.ts +23 -0
- package/src/saeeol/kilocode/spotlight.ts +23 -0
- package/src/saeeol/spotlight.ts +23 -0
- package/src/util/array.ts +10 -0
- package/src/util/binary.ts +41 -0
- package/src/util/effect-flock.ts +283 -0
- package/src/util/encode.ts +52 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +48 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/log.ts +208 -0
- package/src/util/module.ts +10 -0
- package/src/util/path.ts +37 -0
- package/src/util/retry.ts +42 -0
- package/src/util/saeeol-process.ts +24 -0
- package/src/util/slug.ts +74 -0
- package/sst-env.d.ts +10 -0
- package/test/effect/cross-spawn-spawner.test.ts +423 -0
- package/test/effect/observability.test.ts +46 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +60 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/fixture/tmpdir.ts +13 -0
- package/test/global.test.ts +16 -0
- package/test/lib/effect.ts +53 -0
- package/test/npm-config.test.ts +51 -0
- package/test/npm.test.ts +91 -0
- package/test/saeeol/filesystem-containment.test.ts +13 -0
- package/test/saeeol/kilocode/filesystem-containment.test.ts +13 -0
- package/test/util/effect-flock.test.ts +386 -0
- package/test/util/flock.test.ts +426 -0
- 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
|
+
}
|
package/src/flag/flag.ts
ADDED
|
@@ -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
|
+
)
|