index.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. /**
  2. * Immutable launch-time environment snapshot that records which layer
  3. * supplied each value. Harness consumers resolve through it instead of a flattened
  4. * `process.env`; launchers may still materialize accepted values for config
  5. * expressions and third-party libraries.
  6. * @module @deepseek-ai/dsh-launch-environment
  7. */
  8. import type { Context } from '@deepseek-ai/cordis'
  9. /**
  10. * Which layer supplied a value, from most to least trusted: the environment
  11. * this process inherited, the invoking directory's `.env`, the Harness home's
  12. * `.env`.
  13. */
  14. export type LaunchEnvironmentSource = 'process' | 'project-env' | 'user-env'
  15. /** Layer order, most trusted first. */
  16. const SOURCE_ORDER: readonly LaunchEnvironmentSource[] = ['process', 'project-env', 'user-env']
  17. /** One resolved variable and the layer it came from. */
  18. export interface LaunchEnvironmentEntry {
  19. /** The value as the layer supplied it; may be empty, which each owner judges for itself. */
  20. value: string
  21. /** The layer that supplied it. */
  22. source: LaunchEnvironmentSource
  23. /** Absolute path of the file that supplied it; absent for `process`. */
  24. path?: string
  25. }
  26. /**
  27. * The frozen environment of one launch. Construct through
  28. * {@link createLaunchEnvironmentSnapshot}; nothing mutates it afterwards, so a
  29. * later `chdir`, workspace switch, or resumed session observes the same
  30. * values a consumer resolved at boot.
  31. */
  32. export interface LaunchEnvironmentSnapshot {
  33. /**
  34. * Resolve one name across every layer, most trusted first.
  35. * @param name - the variable name.
  36. * @returns the winning entry, or `undefined` when no layer supplies it.
  37. */
  38. get(name: string): LaunchEnvironmentEntry | undefined
  39. /**
  40. * Resolve one name only from `sources`, retaining canonical trust order;
  41. * omitted layers are unreachable.
  42. * @param name - the variable name.
  43. * @param sources - the layers allowed in the canonical trust order.
  44. * @returns the first matching entry, or `undefined`.
  45. */
  46. getFrom(name: string, sources: readonly LaunchEnvironmentSource[]): LaunchEnvironmentEntry | undefined
  47. }
  48. /**
  49. * The map key one variable name resolves under. Windows treats environment
  50. * names case-insensitively; every other platform does not.
  51. * @param name - the variable name as written.
  52. * @returns the key to store and look up by.
  53. */
  54. function lookupKey(name: string): string {
  55. /* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */
  56. return process.platform === 'win32' ? name.toUpperCase() : name
  57. }
  58. /** One layer's raw contents, as {@link createLaunchEnvironmentSnapshot} receives them. */
  59. export interface LaunchEnvironmentLayerInput {
  60. source: LaunchEnvironmentSource
  61. /** Absolute path of the file behind this layer; omit for `process`. */
  62. path?: string
  63. values: Readonly<Record<string, string>>
  64. }
  65. /**
  66. * Build the snapshot from each layer's contents.
  67. * @param layers - the layers in any order; the result searches them by canonical trust order.
  68. * @returns the immutable snapshot.
  69. */
  70. export function createLaunchEnvironmentSnapshot(layers: readonly LaunchEnvironmentLayerInput[]): LaunchEnvironmentSnapshot {
  71. // Copy every layer so later mutations cannot change the snapshot. Fold names
  72. // on Windows so case variants cannot split precedence; POSIX remains exact.
  73. const bySource = new Map<LaunchEnvironmentSource, { path?: string; values: Map<string, string> }>()
  74. for (const layer of layers) {
  75. bySource.set(layer.source, {
  76. ...layer.path === undefined ? {} : { path: layer.path },
  77. values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])),
  78. })
  79. }
  80. const getFrom = (name: string, sources: readonly LaunchEnvironmentSource[]): LaunchEnvironmentEntry | undefined => {
  81. const key = lookupKey(name)
  82. for (const source of SOURCE_ORDER) {
  83. if (!sources.includes(source)) continue
  84. const layer = bySource.get(source)
  85. const value = layer?.values.get(key)
  86. if (value === undefined) continue
  87. return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
  88. }
  89. return undefined
  90. }
  91. return {
  92. get: name => getFrom(name, SOURCE_ORDER),
  93. getFrom,
  94. }
  95. }
  96. /** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
  97. export const DSH_LAUNCH_ENVIRONMENT_KEY = 'launchEnvironment'
  98. /**
  99. * Return the launcher's snapshot, or the inherited environment as the sole
  100. * layer when the host provided none.
  101. * @param ctx - the consuming plugin's context.
  102. * @returns the snapshot to resolve user-facing values against.
  103. */
  104. export function launchEnvironmentOf(ctx: Context): LaunchEnvironmentSnapshot {
  105. return ctx.get(DSH_LAUNCH_ENVIRONMENT_KEY)
  106. ?? createLaunchEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
  107. }
  108. declare module '@deepseek-ai/cordis' {
  109. interface Context {
  110. /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
  111. launchEnvironment?: LaunchEnvironmentSnapshot
  112. }
  113. }