From 6f27e6c2cea0ceeb86ef8887e3e2518e898567f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:44:10 +0800 Subject: [PATCH 1/2] test(windows): compare canonical HMR identities --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 +- .../2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- packages/boot/app-boot/tests/hmr-config.spec.ts | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index cff610e481..cb77330b29 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 258bca96db04c44ca2a3509c297028e31ef5375e -2026-08-08-native-windows-pull-request-ci.zh.md: dd0031630eea9bb09a4a4db0125933ba0c7bac2f +2026-08-08-native-windows-pull-request-ci.md: b8de631f96f7ff27c122971448f115d198b24a60 +2026-08-08-native-windows-pull-request-ci.zh.md: 433904ef513d3f92a6350eeb99f071711b49daf6 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 258bca96db..b8de631f96 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -16,7 +16,7 @@ Every pull request also starts an independent `windows-native` job named `window The native gate keeps workspace build and production-site failures blocking inside its own job while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. -The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index dd0031630e..433904ef51 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 原生门禁在其自身作业内继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 -首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。 diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index d21e222104..82cbd71c28 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -40,7 +41,7 @@ describe('HMR exact config paths', () => { // This acceptance owns alias-to-cache identity. Other cases below exercise // native events; polling keeps Windows fs.watch queue pressure out of it. const ctx = await bootHmr(alias, ['.'], true) - const filename = join(realpathSync(target), 'module.ts') + const filename = join(await realpath(target), 'module.ts') const expected = pathToFileURL(filename).href const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) const observed: string[] = [] @@ -73,7 +74,7 @@ describe('HMR exact config paths', () => { const ctx = await bootHmr(alias) try { await ctx.hmr.registerConfig('plugins.yml', () => {}) - await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {})) + await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {})) .rejects.toThrow('config path already registered') } finally { await ctx.fiber.dispose() From e308268abcbd1adb7ec52c1261f9f7a6bb7266e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:50:13 +0800 Subject: [PATCH 2/2] fix(windows): force isolated repository pnpm wrapper --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../app-boot/tests/repository-cache.spec.ts | 16 +++++++++++- vendor/README.md | 2 +- vendor/loader/src/repository.ts | 25 ++++++++++++++++--- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 9351bdbac1..32ed0e8042 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 58380cbf2f4347b573a1646f0e1350c2a2bc40e9 -2026-08-08-native-windows-pull-request-ci.zh.md: 2e13b0d4ce0acdf9aef9856ea39ee6cd20fc406c +2026-08-08-native-windows-pull-request-ci.md: 7b80f9fbe49b32de19be51b3a0c0e40156ed00ea +2026-08-08-native-windows-pull-request-ci.zh.md: 734ebbf1c1b71d9fbbd4ccf4c0b60f802ad261e9 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 58380cbf2f..7b80f9fbe4 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -26,7 +26,7 @@ Portable filesystem fixtures derive paths with `node:path`, compare native realp Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary. -Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration so a nested Git-package install cannot rejoin an enclosing workspace when Windows lifecycle setup takes precedence over the transaction-owned command wrapper. +Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 2e13b0d4ce..734ebbf1c1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -26,7 +26,7 @@ Status: implemented 原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。 -Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,因此当 Windows 生命周期设置的 PATH 优先级绕过由事务持有的命令包装层时,嵌套的 Git 包安装也不会重新加入外层 workspace。 +Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts index 390d2cc9e8..71aa90a440 100644 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ b/packages/boot/app-boot/tests/repository-cache.spec.ts @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -122,6 +122,10 @@ describe('RepositoryCache', () => { await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) + const shadowPnpm = join(root, 'shadow-pnpm') + await mkdir(shadowPnpm) + await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 }) + await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n') await writeFile(join(repository, 'package.json'), `${JSON.stringify({ name: 'repository-fixture', private: true, @@ -160,6 +164,7 @@ describe('RepositoryCache', () => { "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", + "writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)", '', ].join('\n'), { mode: 0o700 }) await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') @@ -191,10 +196,19 @@ describe('RepositoryCache', () => { const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') + vi.stubEnv('PNPM_HOME', shadowPnpm) + vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter)) + vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE') const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') + const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as { + path: string + pathExt: string + } + expect(environment.path.split(delimiter)).not.toContain(shadowPnpm) + expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD') await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) .toBe('repository skill source\n') diff --git a/vendor/README.md b/vendor/README.md index fa31982101..75f93b2b88 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and inherited `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace, including when Windows lifecycle setup prepends its own command directory to `PATH`. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies. +10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and exported `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace. The child retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT` so a later inherited pnpm executable cannot outrank the wrapper on Windows. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile, obtains both its build and prepare commands from declared dependencies, and rejects an inherited shadow pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts index b24525554b..c85b84ccfc 100644 --- a/vendor/loader/src/repository.ts +++ b/vendor/loader/src/repository.ts @@ -36,16 +36,35 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS. return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) } +function normalizedEnvironmentPath(value: string): string { + const unquoted = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value + const normalized = resolve(unquoted) + return process.platform === 'win32' ? normalized.toUpperCase() : normalized +} + function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { const scrubbed = scrubEnvironment() const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] + const pathExt = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATHEXT')?.[1] + const pnpmHome = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PNPM_HOME')?.[1] + const normalizedPnpmHome = pnpmHome === undefined ? undefined : normalizedEnvironmentPath(pnpmHome) + const inheritedPath = path === undefined ? [] : path.split(delimiter).filter((entry) => { + return normalizedPnpmHome === undefined || normalizedEnvironmentPath(entry) !== normalizedPnpmHome + }) + const pathExtensions = pathExt?.split(';') + const prioritizedPathExt = pathExtensions === undefined ? undefined : [ + ...pathExtensions.filter(extension => extension.toUpperCase() === '.CMD'), + ...pathExtensions.filter(extension => extension.toUpperCase() !== '.CMD'), + ].join(';') const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { - return name.toUpperCase() !== 'PATH' && name.toUpperCase() !== 'PNPM_CONFIG_IGNORE_WORKSPACE' + return !['PATH', 'PATHEXT', 'PNPM_CONFIG_IGNORE_WORKSPACE'].includes(name.toUpperCase()) })) return { ...withoutOverrides, - PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter), - // pnpm prepends its own command directory to lifecycle PATH on Windows. + PATH: [commandDirectory, ...inheritedPath].join(delimiter), + // cmd.exe tests PATHEXT before later PATH entries, so the transaction's + // pnpm.cmd must precede an inherited pnpm executable from PNPM_HOME. + ...(prioritizedPathExt === undefined ? {} : { PATHEXT: prioritizedPathExt }), PNPM_CONFIG_IGNORE_WORKSPACE: 'true', } }