mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
/**
|
|
* Immutable launch-time environment snapshot with per-value source
|
|
* provenance. Harness consumers resolve through it instead of a flattened
|
|
* `process.env`; launchers may still materialize accepted values for config
|
|
* expressions and third-party libraries.
|
|
* @module @deepseek-ai/dsh-environment
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
|
|
/**
|
|
* Which layer supplied a value, from most to least trusted: the environment
|
|
* this process inherited, the invoking directory's `.env`, the Harness home's
|
|
* `.env`.
|
|
*/
|
|
export type EnvironmentSource = 'process' | 'project-env' | 'user-env'
|
|
|
|
/** Layer order, most trusted first. */
|
|
const SOURCE_ORDER: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
|
|
|
|
/** One resolved variable and the layer it came from. */
|
|
export interface EnvironmentEntry {
|
|
/** The value as the layer supplied it; may be empty, which each owner judges for itself. */
|
|
value: string
|
|
/** The layer that supplied it. */
|
|
source: EnvironmentSource
|
|
/** Absolute path of the file that supplied it; absent for `process`. */
|
|
path?: string
|
|
}
|
|
|
|
/**
|
|
* The frozen environment of one launch. Construct through
|
|
* {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a
|
|
* later `chdir`, workspace switch, or resumed session observes the same
|
|
* values a consumer resolved at boot.
|
|
*/
|
|
export interface EnvironmentSnapshot {
|
|
/**
|
|
* Resolve one name across every layer, most trusted first.
|
|
* @param name - the variable name.
|
|
* @returns the winning entry, or `undefined` when no layer supplies it.
|
|
*/
|
|
get(name: string): EnvironmentEntry | undefined
|
|
/**
|
|
* Resolve one name only from `sources`, retaining canonical trust order;
|
|
* omitted layers are unreachable.
|
|
* @param name - the variable name.
|
|
* @param sources - the layers allowed in the canonical trust order.
|
|
* @returns the first matching entry, or `undefined`.
|
|
*/
|
|
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
|
|
}
|
|
|
|
/**
|
|
* The map key one variable name resolves under. Windows treats environment
|
|
* names case-insensitively; every other platform does not.
|
|
* @param name - the variable name as written.
|
|
* @returns the key to store and look up by.
|
|
*/
|
|
function lookupKey(name: string): string {
|
|
/* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */
|
|
return process.platform === 'win32' ? name.toUpperCase() : name
|
|
}
|
|
|
|
/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */
|
|
export interface EnvironmentLayerInput {
|
|
source: EnvironmentSource
|
|
/** Absolute path of the file behind this layer; omit for `process`. */
|
|
path?: string
|
|
values: Readonly<Record<string, string>>
|
|
}
|
|
|
|
/**
|
|
* Build the snapshot from each layer's contents.
|
|
* @param layers - the layers in any order; the result searches them by canonical trust order.
|
|
* @returns the immutable snapshot.
|
|
*/
|
|
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
|
|
// Copy every layer so later mutations cannot change the snapshot. Fold names
|
|
// on Windows so case variants cannot split precedence; POSIX remains exact.
|
|
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
|
|
for (const layer of layers) {
|
|
bySource.set(layer.source, {
|
|
...layer.path === undefined ? {} : { path: layer.path },
|
|
values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])),
|
|
})
|
|
}
|
|
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
|
|
const key = lookupKey(name)
|
|
for (const source of SOURCE_ORDER) {
|
|
if (!sources.includes(source)) continue
|
|
const layer = bySource.get(source)
|
|
const value = layer?.values.get(key)
|
|
if (value === undefined) continue
|
|
return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
|
|
}
|
|
return undefined
|
|
}
|
|
return {
|
|
get: name => getFrom(name, SOURCE_ORDER),
|
|
getFrom,
|
|
}
|
|
}
|
|
|
|
/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
|
|
export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment'
|
|
|
|
/**
|
|
* Return the launcher's snapshot, or the inherited environment as the sole
|
|
* layer when the host provided none.
|
|
* @param ctx - the consuming plugin's context.
|
|
* @returns the snapshot to resolve user-facing values against.
|
|
*/
|
|
export function environmentOf(ctx: Context): EnvironmentSnapshot {
|
|
return ctx.get(DSH_ENVIRONMENT_KEY)
|
|
?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
|
|
}
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
|
|
launcherEnvironment?: EnvironmentSnapshot
|
|
}
|
|
}
|