refactor(credentials,llm): remove speculative mutation and route lifecycle
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
|
||||
README.md: 126140b10719dc6f7bc458a118ba1feb1f440270
|
||||
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d
|
||||
README.md: dcfaea036595c68eb07b104d9075b67d17dad2ac
|
||||
README.zh.md: 635d41a8e156b71d749419e90e408c184e9adbbc
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
|
||||
Read-only [credentials](../credentials/README.md) provider with two externally managed sources:
|
||||
|
||||
| Layer | Source id | Writable | Wins |
|
||||
|---|---|---|---|
|
||||
| Live process environment | `env` | no | always |
|
||||
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
|
||||
| Layer | Wins |
|
||||
|---|---|
|
||||
| Live process environment | Always, when the named value is non-empty |
|
||||
| `$DSH_HOME/.env` document | Otherwise |
|
||||
|
||||
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back.
|
||||
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, or a prepared shell) is operator intent for that process. The provider never writes either source.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -17,28 +17,20 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`,
|
||||
|---|---|---|
|
||||
| `path` | `<harness home>/.env` | Credentials document location. |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
|
||||
| `watch` | `true` | Hot-publish external edits. |
|
||||
| `debounceMs` | `100` | Watcher write-settle window. |
|
||||
|
||||
## The document
|
||||
## Resolution
|
||||
|
||||
dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
|
||||
Each `resolve(ref)` reads `process.env[ref]` first. If it is absent or empty, the provider reads the dotenv document and parses it with `dotenv`; a missing file, missing key, or empty value resolves to `undefined`, while any other file error rejects the operation. Nothing is watched or cached, so an external edit is visible to the next resolution without a provider lifecycle or mutation API.
|
||||
|
||||
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
|
||||
|
||||
## Hot reload
|
||||
|
||||
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
|
||||
The provider accepts dotenv's parsing semantics, including last-assignment precedence. It does not create the document or control its permissions; the operator or external credential-management surface owns both.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
|
||||
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
|
||||
The harness does not expose the resolved document path to the model or hoist the file into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). This is discretion, not isolation: tools run as the same OS user and can read any file that user's permissions allow. A deployment that must keep provider keys away from its own agent needs a provider backed by a store those tool processes cannot read.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
|
||||
Indirectly, through the consuming LLM adapters: resolved values authorize their provider requests, and the adapter owns every model-visible surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -46,9 +38,6 @@ No direct invalidation; credentials never enter a request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly.
|
||||
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
|
||||
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred.
|
||||
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
|
||||
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
|
||||
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
|
||||
- **Mutation is external** — edit the dotenv document, launching environment, or upstream secret store; this provider intentionally has no write API.
|
||||
- **Every file fallback performs I/O** — the implementation favors a small always-current read path over a watcher, cache, and invalidation lifecycle.
|
||||
- **A same-UID process can read the document** — file permissions do not isolate a secret from model-invoked tools running as the same user.
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。
|
||||
只读[凭据](../credentials/README.md)提供方,包含两个由外部管理的来源:
|
||||
|
||||
| 层 | 来源 id | 可写 | 优先 |
|
||||
|---|---|---|---|
|
||||
| 活跃进程环境 | `env` | 否 | 恒定优先 |
|
||||
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 |
|
||||
| 层 | 优先级 |
|
||||
|---|---|
|
||||
| 活跃进程环境 | 点名的值非空时始终优先 |
|
||||
| `$DSH_HOME/.env` 文档 | 其余情况 |
|
||||
|
||||
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
|
||||
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密或预先设置好环境的 shell)代表操作者对该进程的意图。提供方不会写入任何一个来源。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -17,28 +17,20 @@
|
||||
|---|---|---|
|
||||
| `path` | `<harness home>/.env` | 凭据文档位置。 |
|
||||
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 |
|
||||
| `watch` | `true` | 热发布外部编辑。 |
|
||||
| `debounceMs` | `100` | watcher 写入稳定窗口。 |
|
||||
|
||||
## 文档本身
|
||||
## 解析
|
||||
|
||||
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。
|
||||
每次调用 `resolve(ref)` 时,提供方先读取 `process.env[ref]`。若该值不存在或为空,提供方再读取 dotenv 文档并以 `dotenv` 解析;文件不存在、键不存在或值为空时返回 `undefined`,其他文件错误则使该操作失败。实现不使用 watcher 或缓存,因此外部编辑无需经过提供方生命周期或变更 API,即可在下一次解析时生效。
|
||||
|
||||
值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。
|
||||
|
||||
## 热重载
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。
|
||||
提供方接受 dotenv 的解析语义,包括最后一次赋值优先。它既不创建文档,也不控制其权限;两者均归操作者或外部凭据管理接口所有。
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
|
||||
|
||||
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。
|
||||
harness 不会向模型暴露解析后的文档路径,也不会把文件载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。这是审慎,不是隔离:工具以同一 OS 用户身份运行,可以读取该用户权限允许的任何文件。必须让提供方密钥远离自身 agent(智能体)的部署,需要采用由这些工具进程无法读取的存储支撑的提供方。
|
||||
|
||||
## Model Experience
|
||||
|
||||
经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
|
||||
经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -46,9 +38,6 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。
|
||||
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
|
||||
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。
|
||||
- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
|
||||
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
|
||||
- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
|
||||
- **修改由外部完成**:请编辑 dotenv 文档、启动环境或上游机密存储;该提供方刻意不提供写入 API。
|
||||
- **每次回退到文件都会执行 I/O**:实现选择小而始终读取当前值的路径,不引入 watcher、缓存和失效生命周期。
|
||||
- **同一 OS 用户的进程可以读取该文档**:文件权限无法将机密与以同一用户身份运行的模型调用工具隔离。
|
||||
|
||||
@@ -27,19 +27,16 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.2.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
@@ -1,463 +1,72 @@
|
||||
/**
|
||||
* File-backed credentials provider layering the live process environment over
|
||||
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
|
||||
* (a launch-time override must win, and must be visibly read-only rather than
|
||||
* silently shadow writes); the file is the provider-managed writable source:
|
||||
* every write re-reads the document under a cross-process writer lock before
|
||||
* rewriting only its own line — preserving every other byte, physical line
|
||||
* endings and quoted multi-line values included — external edits hot-publish
|
||||
* through the seam, and each reload replaces the snapshot wholesale so a
|
||||
* deleted entry never lingers in memory.
|
||||
* Read-only credential provider layering the live process environment over a
|
||||
* `$DSH_HOME/.env` document read on demand.
|
||||
* @module @deepseek-ai/dsh-credentials-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse } from 'dotenv'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
import { Credentials } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
/** Plugin config: the optional credential document location. */
|
||||
export interface Config {
|
||||
/** Credentials document path; defaults to `.env` under the harness home. */
|
||||
path?: string
|
||||
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Watch the document and hot-publish external edits; defaults to true. */
|
||||
watch?: boolean
|
||||
/** Watcher write-settle window in milliseconds; defaults to 100. */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/** Fully resolved provider parameters; defaulting happens here, never inline. */
|
||||
/** Fully resolved provider parameters. */
|
||||
interface ResolvedSpec {
|
||||
filename: string
|
||||
watch: boolean
|
||||
debounceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime spec from plugin config: an explicit `path` wins,
|
||||
* otherwise the document lives at `<harness home>/.env`.
|
||||
* Resolve the runtime spec from plugin config.
|
||||
* @param config - raw plugin config.
|
||||
* @returns the resolved file location and watch behavior.
|
||||
* @returns the absolute credential document path.
|
||||
*/
|
||||
export function resolveSpec(config: Config): ResolvedSpec {
|
||||
return {
|
||||
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
|
||||
watch: config.watch ?? true,
|
||||
debounceMs: config.debounceMs ?? 100,
|
||||
}
|
||||
return { filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')) }
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure surfaces. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Values that survive a dotenv round-trip without quoting. */
|
||||
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
|
||||
|
||||
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (char.charCodeAt(0) < 0x20) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one `KEY=value` line in the narrowest style dotenv reads back
|
||||
* verbatim: bare, then single quotes (fully literal), then double quotes
|
||||
* (safe only without backslashes, which double-quote reading expands).
|
||||
* A value no style can represent fails loud instead of corrupting silently.
|
||||
*/
|
||||
function renderLine(ref: CredentialRef, value: string): string {
|
||||
if (BARE_VALUE.test(value)) return `${ref}=${value}`
|
||||
if (hasControlCharacters(value)) {
|
||||
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
|
||||
}
|
||||
if (!value.includes('\'')) return `${ref}='${value}'`
|
||||
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
|
||||
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
|
||||
}
|
||||
|
||||
/** Split text into physical lines with their terminators attached. */
|
||||
function physicalLines(text: string): string[] {
|
||||
return text.length === 0 ? [] : text.split(/(?<=\n)/)
|
||||
}
|
||||
|
||||
/** One physical line's content without its terminator. */
|
||||
function lineContent(line: string): string {
|
||||
if (line.endsWith('\r\n')) return line.slice(0, -2)
|
||||
if (line.endsWith('\n')) return line.slice(0, -1)
|
||||
return line
|
||||
}
|
||||
|
||||
/** One physical line's terminator (empty on a final unterminated line). */
|
||||
function lineTerminator(line: string): string {
|
||||
return line.slice(lineContent(line).length)
|
||||
}
|
||||
|
||||
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
|
||||
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
|
||||
|
||||
/** Quote characters dotenv reads across physical lines. */
|
||||
const MULTILINE_QUOTES = ['\'', '"', '`']
|
||||
|
||||
/**
|
||||
* The quote character an assignment's value part opens without closing on its
|
||||
* own line — the following physical lines are that value's continuation, not
|
||||
* assignments — or `undefined` for a single-line value.
|
||||
*/
|
||||
function opensMultiline(valuePart: string): string | undefined {
|
||||
const trimmed = valuePart.trimStart()
|
||||
const quote = trimmed[0]
|
||||
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
|
||||
const rest = trimmed.slice(1)
|
||||
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
|
||||
return body.includes(quote) ? undefined : quote
|
||||
}
|
||||
|
||||
/** Whether a continuation line closes the given quote. */
|
||||
function closesQuote(content: string, quote: string): boolean {
|
||||
const body = quote === '"' ? content.replaceAll('\\"', '') : content
|
||||
return body.includes(quote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace, insert, or delete one reference's assignment while preserving
|
||||
* every other byte: untouched lines keep their exact content and terminators
|
||||
* (CRLF included), and the physical lines inside another key's quoted
|
||||
* multi-line value are never mistaken for assignments. The first matching
|
||||
* assignment is rewritten in place with its own line ending; later duplicates
|
||||
* drop (dotenv reads the last one, so a surviving duplicate would override
|
||||
* the edit); an insert appends in the document's dominant ending style.
|
||||
*/
|
||||
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
|
||||
const lines = physicalLines(text ?? '')
|
||||
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
|
||||
const out: string[] = []
|
||||
let placed = false
|
||||
let pendingQuote: string | undefined
|
||||
for (const line of lines) {
|
||||
const content = lineContent(line)
|
||||
if (pendingQuote !== undefined) {
|
||||
// Inside a quoted multi-line value: never an assignment, always kept.
|
||||
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const match = ASSIGNMENT.exec(content)
|
||||
if (match === null) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const [, key, valuePart] = match
|
||||
if (key !== ref) {
|
||||
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
|
||||
pendingQuote = opensMultiline(valuePart ?? '')
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
// The write path refuses multi-line targets before rendering, so the
|
||||
// matched assignment is single-line and drops or rewrites wholesale.
|
||||
if (rendered !== undefined && !placed) {
|
||||
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
if (rendered !== undefined && !placed) {
|
||||
const last = out[out.length - 1]
|
||||
if (last !== undefined && lineTerminator(last) === '') {
|
||||
out[out.length - 1] = `${last}${dominant}`
|
||||
}
|
||||
out.push(`${rendered}${dominant}`)
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/** File-backed credentials provider (`$DSH_HOME/.env`). */
|
||||
export class CredentialsLocal extends Credentials {
|
||||
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
|
||||
settings-local (prefer symmetry for parallel values); extracting the shared
|
||||
shape would couple the two providers' teardown semantics across packages. */
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string(),
|
||||
dshHome: z.string(),
|
||||
watch: z.boolean().default(true),
|
||||
debounceMs: z.number().min(0).default(100),
|
||||
})
|
||||
|
||||
private readonly spec: ResolvedSpec
|
||||
/**
|
||||
* Raw text of the last read or persisted document; `undefined` while the
|
||||
* file is absent. Watcher events whose content equals this cache are no-ops,
|
||||
* which is also the self-write suppression.
|
||||
*/
|
||||
private text: string | undefined
|
||||
/** Parsed document snapshot; replaced wholesale on every reload. */
|
||||
private values = new Map<string, string>()
|
||||
/**
|
||||
* Single exclusive operation chain: watcher reloads and line edits run one
|
||||
* at a time in queue order (settled tail), so an edit can never render from
|
||||
* text a concurrent reload is busy replacing.
|
||||
*/
|
||||
private operations: Promise<void> = Promise.resolve()
|
||||
/** Set at dispose: refuse new writes and let in-flight work no-op. */
|
||||
private closed = false
|
||||
|
||||
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
|
||||
private isClosed(): boolean {
|
||||
return this.closed
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx)
|
||||
// Programmatic construction may bypass Schemastery normalization; resolve
|
||||
// the same defaults in one explicit step either way.
|
||||
this.spec = resolveSpec(config)
|
||||
}
|
||||
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Drain: refuse new operations, then settle the queued ones so disposal
|
||||
// completes only once storage is quiescent.
|
||||
this.closed = true
|
||||
await this.operations
|
||||
}
|
||||
await this.loadInitial()
|
||||
if (!this.spec.watch) return
|
||||
/* jscpd:ignore-start -- same watcher discipline as settings-local by design:
|
||||
the serialized-refresh and quiesce-on-dispose shape is the reviewed
|
||||
lifecycle contract, not accidental repetition. */
|
||||
const watcher = chokidarWatch(this.spec.filename, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The initial load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
this.closed = true
|
||||
await watcher.close()
|
||||
await this.operations
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
override async resolve(ref: CredentialRef): Promise<string | undefined> {
|
||||
const ambient = process.env[ref]
|
||||
if (ambient !== undefined && ambient.length > 0) return ambient
|
||||
|
||||
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
override describe(ref: CredentialRef): Promise<CredentialInfo> {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
return Promise.resolve({ configured: true, source: 'env', writable: false })
|
||||
}
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined && stored.length > 0) {
|
||||
// A quoted multi-line value resolves fine but the line editor refuses to
|
||||
// rewrite it, so writability must say what set() would actually do.
|
||||
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
|
||||
}
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
|
||||
override async set(ref: CredentialRef, value: string): Promise<void> {
|
||||
if (value.length === 0) {
|
||||
throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
|
||||
}
|
||||
await this.write(ref, value)
|
||||
}
|
||||
|
||||
override async unset(ref: CredentialRef): Promise<void> {
|
||||
await this.write(ref, undefined)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
|
||||
reviewed contract as settings-local, deliberately mirrored (prefer symmetry
|
||||
for parallel values); the two providers own different documents and
|
||||
failure policies, so extracting the shape would couple their teardown
|
||||
semantics across packages for a handful of lines. */
|
||||
/** Queue one exclusive document operation behind every earlier one. */
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const task = this.operations.then(operation)
|
||||
this.operations = task.then(() => undefined, () => undefined)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
|
||||
private queueRefresh(): void {
|
||||
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the update fan-out can reject a
|
||||
// refresh; keep the operation queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
|
||||
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
|
||||
const verb = value === undefined ? 'unset' : 'set'
|
||||
if (this.isClosed()) {
|
||||
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
|
||||
}
|
||||
this.assertUnshadowed(ref, verb)
|
||||
return this.enqueue(async () => {
|
||||
if (this.isClosed()) {
|
||||
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
|
||||
}
|
||||
// Re-judged at run time: the environment may have changed while queued.
|
||||
this.assertUnshadowed(ref, verb)
|
||||
// The writer lock's exclusive create needs the parent to exist; 0700
|
||||
// because the harness home holds user-private data.
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
// Read-modify-write: fold in any on-disk state this process has not
|
||||
// observed yet — an external edit still inside the watcher debounce
|
||||
// window, a change the watcher missed, or another process's write —
|
||||
// so the line edit below can never resurrect a stale document.
|
||||
await this.reconcileFromDisk()
|
||||
const existing = this.values.get(ref)
|
||||
if (value === undefined && existing === undefined) return
|
||||
if (existing !== undefined && existing.includes('\n')) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
|
||||
)
|
||||
}
|
||||
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
|
||||
// 0600: a document holding secrets is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
|
||||
this.text = nextText
|
||||
if (value === undefined) this.values.delete(ref)
|
||||
else this.values.set(ref, value)
|
||||
// After the commit: a broken observer must never make the durable
|
||||
// write look failed (an INVARIANT failure still rethrows).
|
||||
this.notifyUpdated(ref)
|
||||
}, {
|
||||
onStaleBreak: (lockPath) => {
|
||||
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject a write the live environment would shadow into apparent no-effect. */
|
||||
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
|
||||
const env = process.env[ref]
|
||||
if (env !== undefined && env.length > 0) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
|
||||
+ ' shadowed; change the launching environment instead',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot read: an absent file is an empty store; any other failure is loud. */
|
||||
private async loadInitial(): Promise<void> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
this.text = text
|
||||
this.values = new Map(Object.entries(parse(text)))
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
|
||||
reconcile policy: warn-and-keep on a reload, throw on a write, invariant
|
||||
failures propagate. */
|
||||
/**
|
||||
* Re-read the document after a watcher event. Unchanged content (including
|
||||
* this provider's own writes) is a no-op; an unreadable document keeps the
|
||||
* last good snapshot and warns — a live hot-reload must never take the
|
||||
* process down. An invariant violation escaping the fan-out is not a reload
|
||||
* failure and propagates to the queue's error surface.
|
||||
*/
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.closed) return
|
||||
try {
|
||||
await this.reconcileFromDisk()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
||||
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the on-disk text against the cache and publish any difference
|
||||
* into the seam. Absence publishes the empty store; an unreadable file
|
||||
* throws, so each caller picks its policy — a reload warns and keeps the
|
||||
* last good snapshot, a write fails loud. dotenv parsing is lenient by
|
||||
* design and cannot fail.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
|
||||
const changed = this.changedRefs(this.values, next)
|
||||
this.text = text
|
||||
this.values = next
|
||||
for (const ref of changed) this.notifyUpdated(ref)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Seam-addressable entries whose effective (non-empty) value changed. */
|
||||
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
|
||||
const changed: CredentialRef[] = []
|
||||
for (const key of new Set([...prev.keys(), ...next.keys()])) {
|
||||
const before = prev.get(key)
|
||||
const after = next.get(key)
|
||||
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
|
||||
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
|
||||
if (effectiveBefore === effectiveAfter) continue
|
||||
try {
|
||||
changed.push(credentialRef(key))
|
||||
} catch (_unaddressableKey) {
|
||||
// A key that is not a POSIX identifier is preserved file content the
|
||||
// seam cannot address, so no observer could ever see it change.
|
||||
}
|
||||
}
|
||||
return changed
|
||||
const stored = parse(text)[ref]
|
||||
return stored === undefined || stored.length === 0 ? undefined : stored
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ export const name = 'credentials-local-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the
|
||||
* `credentials/updated` lifecycle contract; this provider's file/environment layering is
|
||||
* No runtime invariant: this provider's file/environment layering is
|
||||
* asynchronous I/O pinned by its unit suite.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
// The atomic write is the gated asynchronous hold point inside a queued
|
||||
// write; gating it makes the dispose-versus-queued-write race fully
|
||||
// deterministic. The lock helper passes through so the gated operation still
|
||||
// runs inside its real acquire/release cycle.
|
||||
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
|
||||
let gate: Promise<void> = Promise.resolve()
|
||||
return {
|
||||
...actual,
|
||||
writeFileAtomic: vi.fn(() => gate),
|
||||
__setGate: (next: Promise<void>) => {
|
||||
gate = next
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
async function setGate(next: Promise<void>): Promise<void> {
|
||||
const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise<void>) => void }
|
||||
mocked.__setGate(next)
|
||||
}
|
||||
|
||||
const KEY = credentialRef('DSH_CRED_DRAIN_A')
|
||||
const OTHER = credentialRef('DSH_CRED_DRAIN_B')
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
await setGate(Promise.resolve())
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
describe('write-drain teardown', () => {
|
||||
it('lets the in-flight write land and fails the queued one after disposal', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await fiber
|
||||
const service = ctx.credentials
|
||||
|
||||
let release!: () => void
|
||||
await setGate(new Promise<void>((resolveGate) => {
|
||||
release = resolveGate
|
||||
}))
|
||||
const first = service.set(KEY, 'one')
|
||||
// Let the first task pass its liveness checks and park on the gate, so it
|
||||
// is genuinely in-flight when disposal begins.
|
||||
await new Promise(resolvePause => setTimeout(resolvePause, 5))
|
||||
// Attach the rejection handler up front: the queued write fails while the
|
||||
// drain is still awaited, before any later `await expect` could run.
|
||||
const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/)
|
||||
const disposal = fiber.dispose()
|
||||
// Give the drain disposer its first turn (set closed) before opening the gate.
|
||||
await new Promise(resolvePause => setTimeout(resolvePause, 10))
|
||||
release()
|
||||
await disposal
|
||||
|
||||
await expect(first).resolves.toBeUndefined()
|
||||
await secondRejects
|
||||
expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
|
||||
expect(await service.resolve(OTHER)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
|
||||
|
||||
const KEY = credentialRef('DSH_CRED_TEST')
|
||||
const OTHER = credentialRef('DSH_CRED_OTHER')
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -23,222 +21,69 @@ async function tempDir(): Promise<string> {
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
|
||||
async function boot(path: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, config)
|
||||
cleanups.push(async () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
await fiber
|
||||
await ctx.plugin(CredentialsLocal, { path })
|
||||
cleanups.push(async () => { await ctx.fiber.dispose() })
|
||||
return ctx
|
||||
}
|
||||
|
||||
function updates(ctx: Context): CredentialRef[] {
|
||||
const seen: CredentialRef[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('resolveSpec', () => {
|
||||
it('defaults to .env under the harness home with watching on', () => {
|
||||
const spec = resolveSpec({ dshHome: '/custom/home' })
|
||||
expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 })
|
||||
it('defaults to .env under the harness home', () => {
|
||||
expect(resolveSpec({ dshHome: '/custom/home' }))
|
||||
.toEqual({ filename: resolve('/custom/home/.env') })
|
||||
})
|
||||
|
||||
it('lets an explicit path win over the home', () => {
|
||||
const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 })
|
||||
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 })
|
||||
expect(resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored' }))
|
||||
.toEqual({ filename: resolve('/etc/dsh/creds.env') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('layering and reads', () => {
|
||||
it('treats an absent file as an empty writable store', async () => {
|
||||
describe('read-only resolution', () => {
|
||||
it('treats an absent file as unconfigured', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
const ctx = await boot(join(dir, '.env'))
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
})
|
||||
|
||||
it('serves file entries, including export-prefixed and quoted values', async () => {
|
||||
it('parses export-prefixed, quoted, and multiline dotenv values', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
|
||||
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
|
||||
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="line one\nline two"\n')
|
||||
const ctx = await boot(path)
|
||||
expect(await ctx.credentials.resolve(KEY)).toBe('plain')
|
||||
expect(await ctx.credentials.resolve(OTHER)).toBe('line one\nline two')
|
||||
})
|
||||
|
||||
it('lets a non-empty process environment win read-only over the file', async () => {
|
||||
it('reads the live environment first on every call', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=from-file\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const ctx = await boot(path)
|
||||
vi.stubEnv('DSH_CRED_TEST', 'from-env')
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
|
||||
expect(await ctx.credentials.resolve(KEY)).toBe('from-env')
|
||||
vi.stubEnv('DSH_CRED_TEST', '')
|
||||
expect(await ctx.credentials.resolve(KEY)).toBe('from-file')
|
||||
})
|
||||
|
||||
it('treats empty values as absent in both layers', async () => {
|
||||
it('re-reads the file on every call and treats empty values as absent', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=first\n')
|
||||
const ctx = await boot(path)
|
||||
expect(await ctx.credentials.resolve(KEY)).toBe('first')
|
||||
await writeFile(path, 'DSH_CRED_TEST=second\n')
|
||||
expect(await ctx.credentials.resolve(KEY)).toBe('second')
|
||||
await writeFile(path, 'DSH_CRED_TEST=\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
vi.stubEnv('DSH_CRED_TEST', '')
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
|
||||
})
|
||||
|
||||
it('fails boot loud when the document exists but cannot be read', async () => {
|
||||
it('surfaces non-absence read failures at resolution time', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'occupied')
|
||||
await mkdir(path)
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('line-editing writes', () => {
|
||||
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen = updates(ctx)
|
||||
await ctx.credentials.set(KEY, 'sk-fresh')
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n')
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(KEY, 'new value!')
|
||||
expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n')
|
||||
})
|
||||
|
||||
it('quotes hostile values so they round-trip through a fresh provider', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const singleQuoted = 'with "quote", back\\slash and space'
|
||||
const doubleQuoted = "it's got an apostrophe"
|
||||
await ctx.credentials.set(KEY, singleQuoted)
|
||||
await ctx.credentials.set(OTHER, doubleQuoted)
|
||||
const reread = await boot({ path, watch: false })
|
||||
expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' })
|
||||
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' })
|
||||
})
|
||||
|
||||
it('fails loud on values no .env quoting style reads back verbatim', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/)
|
||||
await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
|
||||
})
|
||||
|
||||
it('unsets only the owning line and keeps an absent unset silent', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen = updates(ctx)
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n')
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('rejects empty values, shadowed writes, and multi-line entries', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
|
||||
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/)
|
||||
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/)
|
||||
|
||||
vi.stubEnv('DSH_CRED_TEST', 'shadowing')
|
||||
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
|
||||
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
|
||||
})
|
||||
|
||||
it('leaves an empty document after unsetting the only entry', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_TEST=only\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.unset(KEY)
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
})
|
||||
|
||||
it('chains past a rejected write so one bad value cannot poison the queue', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
|
||||
const good = ctx.credentials.set(OTHER, 'lands')
|
||||
await bad
|
||||
await good
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
|
||||
})
|
||||
|
||||
it('serializes concurrent writes so both land in the one document', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await Promise.all([
|
||||
ctx.credentials.set(KEY, 'one'),
|
||||
ctx.credentials.set(OTHER, 'two'),
|
||||
])
|
||||
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n')
|
||||
})
|
||||
|
||||
it('refuses writes after disposal', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await fiber
|
||||
// Capture the handle first: disposal also removes the ctx.credentials service.
|
||||
const service = ctx.credentials
|
||||
await fiber.dispose()
|
||||
await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('real hot reload', () => {
|
||||
it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
// Watching starts on an existing document: creation racing watcher setup
|
||||
// is a chokidar readiness gap, not the reload contract under test.
|
||||
await writeFile(path, 'DSH_CRED_TEST=boot\n')
|
||||
const ctx = await boot({ path, debounceMs: 10 })
|
||||
const seen = updates(ctx)
|
||||
|
||||
await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
|
||||
})
|
||||
|
||||
// Wholesale replacement: an entry deleted on disk never lingers in memory.
|
||||
await writeFile(path, 'DSH_CRED_TEST=live\n')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
|
||||
})
|
||||
|
||||
const before = seen.length
|
||||
await ctx.credentials.set(KEY, 'self-written')
|
||||
await new Promise(resolvePause => setTimeout(resolvePause, 200))
|
||||
// Exactly the committed write's own event: the watcher echo of our own
|
||||
// content is recognized by the text cache and publishes nothing extra.
|
||||
expect(seen.length).toBe(before + 1)
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' })
|
||||
const ctx = await boot(path)
|
||||
await expect(ctx.credentials.resolve(KEY)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// Third-review behaviors: read-modify-write under the writer lock (external
|
||||
// edits survive an API write), the contained credentials/updated fan-out (a
|
||||
// broken observer never fails a committed write), and the physical-line
|
||||
// editor's multi-line and CRLF discipline.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
|
||||
const BETA = credentialRef('DSH_REVIEW_BETA')
|
||||
const INNER = credentialRef('DSH_REVIEW_INNER')
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('read-modify-write', () => {
|
||||
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
// The external edit has landed on disk but no watcher reported it (watch
|
||||
// is off — the same blind spot as a debounce window or a missed event).
|
||||
await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`)
|
||||
await ctx.credentials.set(ALPHA, 'two')
|
||||
const text = await readFile(path, 'utf8')
|
||||
expect(text).toContain(`${BETA}=external`)
|
||||
expect(text).toContain(`${ALPHA}=two`)
|
||||
// The fold published the unobserved entry before the write's own commit.
|
||||
expect(seen).toEqual([ALPHA, BETA, ALPHA])
|
||||
expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' })
|
||||
})
|
||||
|
||||
it('keeps both refs when two providers write the same document concurrently', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const first = await boot({ path, watch: false })
|
||||
const second = await boot({ path, watch: false })
|
||||
await Promise.all([
|
||||
(async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(),
|
||||
(async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(),
|
||||
])
|
||||
const third = await boot({ path, watch: false })
|
||||
expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' })
|
||||
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
|
||||
})
|
||||
|
||||
it('breaks a stale writer lock with a warning and writes through', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await writeFile(`${path}.lock`, 'crashed-holder\n')
|
||||
const past = (Date.now() - 60_000) / 1000
|
||||
await utimes(`${path}.lock`, past, past)
|
||||
await ctx.credentials.set(ALPHA, 'nine')
|
||||
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
|
||||
})
|
||||
|
||||
it('creates the credentials directory owner-only', async () => {
|
||||
const dir = await tempDir()
|
||||
const home = join(dir, 'home')
|
||||
const ctx = await boot({ path: join(home, '.env'), watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'one')
|
||||
expect((await stat(home)).mode & 0o777).toBe(0o700)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contained update fan-out', () => {
|
||||
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
throw new Error('observer boom')
|
||||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('credentials/updated', second)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
|
||||
expect(second).toHaveBeenCalledWith(ALPHA)
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
|
||||
})
|
||||
|
||||
it('contains an async listener rejection', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, '.env'), watch: false })
|
||||
// An unknown-returning function keeps the typed surface legal while the
|
||||
// runtime value is still the rejected promise the containment must handle.
|
||||
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
|
||||
ctx.on('credentials/updated', boom)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
|
||||
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
ctx.on('credentials/updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('credentials/updated', second)
|
||||
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
|
||||
// Harness-fatal by design — but the write itself committed first.
|
||||
expect(second).toHaveBeenCalledWith(ALPHA)
|
||||
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`)
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('physical-line editor', () => {
|
||||
it('never mistakes a quoted multi-line continuation for an assignment', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n`
|
||||
await writeFile(path, wrapped)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
// The wrapped value survives byte-for-byte; only ALPHA's line changed.
|
||||
const afterAlpha = await readFile(path, 'utf8')
|
||||
expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`)
|
||||
// Setting the inner-looking ref appends a real assignment; the
|
||||
// continuation line inside the quoted value stays untouched.
|
||||
await ctx.credentials.set(INNER, 'real')
|
||||
const afterInner = await readFile(path, 'utf8')
|
||||
expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`)
|
||||
expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' })
|
||||
})
|
||||
|
||||
it('preserves CRLF line endings on untouched and edited lines', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`)
|
||||
await ctx.credentials.set(INNER, 'new')
|
||||
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`)
|
||||
})
|
||||
|
||||
it('terminates a final unterminated line before appending', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}=a`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(BETA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`)
|
||||
})
|
||||
|
||||
it('rewrites a final unterminated assignment in the dominant ending style', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}=a`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'b')
|
||||
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`)
|
||||
})
|
||||
|
||||
it('tracks a single-quoted multi-line value through its continuation', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
await ctx.credentials.set(ALPHA, 'x')
|
||||
expect(await readFile(path, 'utf8'))
|
||||
.toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`)
|
||||
})
|
||||
|
||||
it('reports a multi-line entry as unwritable and refuses to edit it', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${ALPHA}="line1\nline2"\n`)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false })
|
||||
await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/)
|
||||
await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/)
|
||||
// Resolution still serves the multi-line value.
|
||||
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' })
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '../src/index.ts'
|
||||
|
||||
// chokidar is the nondeterministic OS boundary: faking it lets these tests
|
||||
// drive the event pipeline (error events, races with unreadable files)
|
||||
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
|
||||
vi.mock('chokidar', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class FakeWatcher extends EventEmitter {
|
||||
close = vi.fn(() => Promise.resolve())
|
||||
}
|
||||
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
|
||||
return {
|
||||
watch: vi.fn((path: string, options: unknown) => {
|
||||
const watcher = new FakeWatcher()
|
||||
instances.push({ path, options, watcher })
|
||||
return watcher
|
||||
}),
|
||||
__instances: instances,
|
||||
}
|
||||
})
|
||||
|
||||
interface FakeChokidar {
|
||||
__instances: Array<{
|
||||
path: string
|
||||
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
|
||||
watcher: import('node:events').EventEmitter
|
||||
}>
|
||||
}
|
||||
|
||||
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
|
||||
const chokidar = await import('chokidar') as unknown as FakeChokidar
|
||||
return chokidar.__instances
|
||||
}
|
||||
|
||||
const KEY = credentialRef('DSH_CRED_PIPE')
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
;(await fakeInstances()).length = 0
|
||||
})
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, config)
|
||||
cleanups.push(async () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('watcher pipeline', () => {
|
||||
it('clamps the write-settle poll interval for a zero debounce', async () => {
|
||||
const dir = await tempDir()
|
||||
await boot({ path: join(dir, '.env'), debounceMs: 0 })
|
||||
const [instance] = await fakeInstances()
|
||||
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
|
||||
})
|
||||
|
||||
it('survives a watcher error and keeps publishing later edits', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
instance!.watcher.emit('error', new Error('watch backend failure'))
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=good\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
|
||||
await chmod(path, 0o000)
|
||||
cleanups.push(() => chmod(path, 0o600))
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' })
|
||||
})
|
||||
|
||||
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
let arm = true
|
||||
ctx.on('credentials/updated', () => {
|
||||
if (!arm) return
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
const [instance] = await fakeInstances()
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=first\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
// The snapshot commits before the fan-out, so the value lands even though
|
||||
// the listener threw out of the refresh.
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' })
|
||||
})
|
||||
|
||||
arm = false
|
||||
await writeFile(path, 'DSH_CRED_PIPE=second\n')
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
|
||||
})
|
||||
})
|
||||
|
||||
it('quiesces the refresh pipeline before dispose completes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
|
||||
await fiber
|
||||
let disposed = false
|
||||
let postDisposeCommits = 0
|
||||
ctx.on('credentials/updated', () => {
|
||||
if (disposed) postDisposeCommits += 1
|
||||
})
|
||||
|
||||
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
|
||||
const [instance] = await fakeInstances()
|
||||
// Two queued refreshes: dispose interrupts one mid-flight and the other
|
||||
// before it starts, so both closed guards must hold.
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await fiber.dispose()
|
||||
disposed = true
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
instance!.watcher.emit('ready')
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(postDisposeCommits).toBe(0)
|
||||
})
|
||||
|
||||
it('empties the snapshot when the document is deleted and emits the removals', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
|
||||
await rm(path)
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'unlink', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
})
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const seen: string[] = []
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
seen.push(ref)
|
||||
})
|
||||
|
||||
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'change', path)
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
|
||||
})
|
||||
// The dash-named key is preserved file content the seam cannot address:
|
||||
// its change publishes nothing and breaks nothing.
|
||||
expect(seen).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('treats an event for a still-absent file as a no-op', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('all', 'add', path)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reconciles at watcher ready so a change during setup is not missed', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, '.env')
|
||||
await writeFile(path, `${KEY}=a\n`)
|
||||
const ctx = await boot({ path, debounceMs: 5 })
|
||||
// Written after the initial load but before the watcher became active:
|
||||
// no 'all' event will ever fire for it.
|
||||
await writeFile(path, `${KEY}=written-before-ready\n`)
|
||||
const [instance] = await fakeInstances()
|
||||
instance!.watcher.emit('ready')
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user