From a72cb311b310007398bd5d4a781d2a4616ffe133 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Tue, 11 Aug 2026 03:56:59 -0400 Subject: [PATCH 1/2] fix(skills): treat aliased agent homes as one skill host Two supported agents can point at the same physical skills directory, for example `~/.claude/skills` symlinked to `~/.agents/skills`. Host resolution treated them as separate install targets, so publishing a registry skill copied into that one directory from two concurrent tasks: one removed the target while the other was writing it, and the second copy failed with `EEXIST: file already exists, mkdir `. Startup synchronization carried the same race, where it only surfaced as a logged warning. Hosts are now collapsed by the real path of their skills directory, falling back to the resolved home directory while that directory does not exist yet, so an aliased home collapses before its first publication. The always-provisioned universal host yields to a concrete agent sharing its directory, because bundled skill content is rendered per agent and the concrete agent's rendering is the more specific one. Signed-off-by: Kevin Cui --- docs/commands.md | 5 ++ docs/commands.zh-CN.md | 4 + .../commands/skills/__tests__/helpers.ts | 14 +++ src/application/commands/skills/index.test.ts | 81 ++++++++++++++++- .../skills/managed-skill-hosts.test.ts | 77 ++++++++++++++++ .../commands/skills/managed-skill-hosts.ts | 87 ++++++++++++++++++- 6 files changed, 265 insertions(+), 3 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 06dd1ca8..8e8947c6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1145,6 +1145,11 @@ missing), and for every other supported host directory that already exists. any newly detected supported host that is missing it. - Local skills: agent-native local skills are not synchronized during startup. A local skill belongs to the agent skill directory where it was created. +- Shared host directories: supported hosts whose skill directories resolve to + the same location — for example `~/.claude/skills` symlinked to + `~/.agents/skills` — are one host. The skill is installed there once and is + reported under the more specific agent: the universal `~/.agents` host yields + to a detected agent that shares its directory. - Migration: startup synchronization does not rewrite same-version legacy symlink targets. Use `oo skills add` for bundled skills and `oo skills update` for registry skills to replace legacy symlinks explicitly. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 84ad9f99..a4b50ca0 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -963,6 +963,10 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- 到且尚未安装它的受支持 Agent。 - 本地 skill:agent-native local skill 不会在启动时同步。本地 skill 归属于创建它 的 Agent skill 目录。 +- 共享 Agent 目录:如果多个受支持 Agent 的 skill 目录指向同一个位置(例如 + `~/.claude/skills` 软链接到 `~/.agents/skills`),它们会被视为同一个 host: + skill 只安装一次,并归属到更具体的 Agent,即通用 `~/.agents` host 让位于与 + 它共享目录的已检测 Agent。 - 迁移:启动同步不会改写同版本的历史软链接目标。请用 `oo skills add` 刷新内置 skill,用 `oo skills update` 刷新 registry skill,从而显式替换历史软链接。成功 的 `oo install` / `oo update` 会运行这两个维护步骤。 diff --git a/src/application/commands/skills/__tests__/helpers.ts b/src/application/commands/skills/__tests__/helpers.ts index 9505480b..83547424 100644 --- a/src/application/commands/skills/__tests__/helpers.ts +++ b/src/application/commands/skills/__tests__/helpers.ts @@ -62,6 +62,20 @@ export async function readBundledSkillSourceContent( return await readBundledSkillFileContent(file); } +// Links an existing directory without touching its contents. Windows CI does +// not grant directory symlink privileges; junctions resolve through realpath() +// the same way. +export async function createDirectorySymbolicLinkForTest( + targetPath: string, + linkPath: string, +): Promise { + await symlink( + targetPath, + linkPath, + process.platform === "win32" ? "junction" : "dir", + ); +} + export async function createSymbolicLinkForTest( targetPath: string, linkPath: string, diff --git a/src/application/commands/skills/index.test.ts b/src/application/commands/skills/index.test.ts index a6fea4f3..807ed9fe 100644 --- a/src/application/commands/skills/index.test.ts +++ b/src/application/commands/skills/index.test.ts @@ -17,7 +17,11 @@ import { parseTelemetryRowPayload, readTelemetryRowsForTest, } from "../../telemetry/outbox.ts"; -import { readBundledSkillSourceContent, seedRegistrySkill } from "./__tests__/helpers.ts"; +import { + createDirectorySymbolicLinkForTest, + readBundledSkillSourceContent, + seedRegistrySkill, +} from "./__tests__/helpers.ts"; import { canonicalLocalSkillsDirectoryName, resolveBundledSkillCanonicalDirectoryPath, @@ -399,6 +403,81 @@ describe("skills commands", () => { } }); + test("installs a registry skill once when two agent homes share a skills directory", async () => { + const sandbox = await createCliSandbox(); + const universalHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "universal"); + const claudeHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "claude"); + const sharedSkillsDirectoryPath = join(sandbox.env.HOME!, ".shared", "skills"); + const skillDirectoryPath = resolveManagedSkillDirectoryPath( + claudeHomeDirectory, + "runtime", + ); + + try { + await mkdir(sharedSkillsDirectoryPath, { recursive: true }); + await mkdir(universalHomeDirectory, { recursive: true }); + await mkdir(claudeHomeDirectory, { recursive: true }); + await createDirectorySymbolicLinkForTest( + sharedSkillsDirectoryPath, + join(universalHomeDirectory, "skills"), + ); + await createDirectorySymbolicLinkForTest( + sharedSkillsDirectoryPath, + join(claudeHomeDirectory, "skills"), + ); + await writeAuthFile(sandbox); + + const result = await sandbox.run( + ["skills", "install", "oo@0.0.2"], + { + fetcher: async (input, init) => { + const request = toRequest(input, init); + + if (request.url.includes("/package-info/")) { + return new Response(JSON.stringify({ + packageName: "oo", + version: "0.0.2", + skills: [ + { + description: "Run OO workflows", + name: "runtime", + title: "Runtime", + }, + ], + })); + } + + if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) { + return new Response(await createRegistrySkillArchiveBytes({ + "package/package/skills/runtime/SKILL.md": "# Runtime\n", + })); + } + + if (isRegistryPackageDownloadCountRequest(request)) { + return new Response(null, { status: 204 }); + } + + throw new Error(`Unexpected request: ${request.url}`); + }, + }, + ); + + // Both homes publish into the same directory: publishing them as two + // hosts copied into the same path concurrently and failed with EEXIST. + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + `Skill: runtime\nInstalled skill runtime to ${skillDirectoryPath}.\n`, + ); + expect( + await readFile(join(sharedSkillsDirectoryPath, "runtime", "SKILL.md"), "utf8"), + ).toContain("# Runtime"); + } + finally { + await sandbox.cleanup(); + } + }); + test("explicit bundled skill install overwrites a managed development-version installation", async () => { const sandbox = await createCliSandbox(); const universalHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "universal"); diff --git a/src/application/commands/skills/managed-skill-hosts.test.ts b/src/application/commands/skills/managed-skill-hosts.test.ts index 5717c052..dfc01d27 100644 --- a/src/application/commands/skills/managed-skill-hosts.test.ts +++ b/src/application/commands/skills/managed-skill-hosts.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { createTemporaryDirectory } from "../../../../__tests__/helpers.ts"; +import { createDirectorySymbolicLinkForTest } from "./__tests__/helpers.ts"; import { resolveAvailableManagedSkillHosts } from "./managed-skill-hosts.ts"; describe("resolveAvailableManagedSkillHosts", () => { @@ -42,4 +43,80 @@ describe("resolveAvailableManagedSkillHosts", () => { await rm(rootDirectory, { force: true, recursive: true }); } }); + + test("collapses hosts whose skills directories resolve to the same path", async () => { + const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); + const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + + try { + const sharedSkillsDirectory = join(rootDirectory, ".shared", "skills"); + + await mkdir(sharedSkillsDirectory, { recursive: true }); + await mkdir(join(rootDirectory, ".agents"), { recursive: true }); + await mkdir(join(rootDirectory, ".claude"), { recursive: true }); + await createDirectorySymbolicLinkForTest( + sharedSkillsDirectory, + join(rootDirectory, ".agents", "skills"), + ); + await createDirectorySymbolicLinkForTest( + sharedSkillsDirectory, + join(rootDirectory, ".claude", "skills"), + ); + + const hosts = await resolveAvailableManagedSkillHosts(env); + + // Both homes publish into the same directory, so they are one host; + // the universal fallback yields to the concrete agent. + expect(hosts.map(host => host.agentName)).toEqual(["claude"]); + expect(hosts[0]!.homeDirectory).toBe(join(rootDirectory, ".claude")); + } + finally { + await rm(rootDirectory, { force: true, recursive: true }); + } + }); + + test("keeps the first concrete agent when two concrete agents share a skills directory", async () => { + const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); + const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + + try { + const claudeSkillsDirectory = join(rootDirectory, ".claude", "skills"); + + await mkdir(claudeSkillsDirectory, { recursive: true }); + await mkdir(join(rootDirectory, ".trae-cn"), { recursive: true }); + await createDirectorySymbolicLinkForTest( + claudeSkillsDirectory, + join(rootDirectory, ".trae-cn", "skills"), + ); + + const hosts = await resolveAvailableManagedSkillHosts(env); + + // The universal host has its own (still missing) skills directory, so + // only the two aliased concrete agents collapse. + expect(hosts.map(host => host.agentName)).toEqual(["universal", "claude"]); + } + finally { + await rm(rootDirectory, { force: true, recursive: true }); + } + }); + + test("collapses hosts whose home directories are aliased before any skills directory exists", async () => { + const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); + const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + + try { + await mkdir(join(rootDirectory, ".agents"), { recursive: true }); + await createDirectorySymbolicLinkForTest( + join(rootDirectory, ".agents"), + join(rootDirectory, ".claude"), + ); + + const hosts = await resolveAvailableManagedSkillHosts(env); + + expect(hosts.map(host => host.agentName)).toEqual(["claude"]); + } + finally { + await rm(rootDirectory, { force: true, recursive: true }); + } + }); }); diff --git a/src/application/commands/skills/managed-skill-hosts.ts b/src/application/commands/skills/managed-skill-hosts.ts index a399331c..32af0868 100644 --- a/src/application/commands/skills/managed-skill-hosts.ts +++ b/src/application/commands/skills/managed-skill-hosts.ts @@ -1,5 +1,6 @@ import type { BundledSkillAgentName } from "./managed-skill-agents.ts"; +import { realpath } from "node:fs/promises"; import { CliUserError } from "../../contracts/cli.ts"; import { directoryExists } from "./bundled-skill-observation.ts"; import { @@ -7,7 +8,10 @@ import { readManagedSkillAgent, resolveManagedSkillAgentHomeDirectory, } from "./managed-skill-agents.ts"; -import { resolveManagedSkillDirectoryPath } from "./managed-skill-paths.ts"; +import { + resolveManagedSkillDirectoryPath, + resolveManagedSkillsDirectoryPath, +} from "./managed-skill-paths.ts"; export interface ManagedSkillHost { agentName: BundledSkillAgentName; @@ -43,7 +47,86 @@ export async function resolveAvailableManagedSkillHosts( }), ); - return hosts.filter(host => host !== undefined); + return collapseAliasedManagedSkillHosts( + hosts.filter(host => host !== undefined), + ); +} + +// Several supported agents can share one physical skills directory: a +// symlinked `~/.claude/skills -> ~/.agents/skills`, or two `*_HOME` overrides +// pointing at the same place. Such hosts are a single install target, and +// treating them as separate ones publishes the same skill into the same +// directory twice — concurrently, so one copy fails with EEXIST while the +// other is still writing. Hosts are therefore collapsed by the real path of +// their skills directory. The always-provisioned universal host yields to a +// concrete agent sharing its directory, because skill content is rendered per +// agent and the concrete agent's rendering is the more specific one; otherwise +// the first host in agent declaration order is kept. +async function collapseAliasedManagedSkillHosts( + hosts: readonly ManagedSkillHost[], +): Promise { + const identifiedHosts = await Promise.all( + hosts.map(async host => ({ + host, + skillsDirectoryIdentity: await resolveManagedSkillsDirectoryIdentity( + host.homeDirectory, + ), + })), + ); + const hostsByIdentity = new Map(); + + for (const identifiedHost of identifiedHosts) { + const collapsedHost = hostsByIdentity.get( + identifiedHost.skillsDirectoryIdentity, + ); + + if ( + collapsedHost !== undefined + && readManagedSkillAgent(collapsedHost.agentName).alwaysProvision !== true + ) { + continue; + } + + hostsByIdentity.set( + identifiedHost.skillsDirectoryIdentity, + identifiedHost.host, + ); + } + + return [...hostsByIdentity.values()]; +} + +// A host is identified by the real path of the directory its skills are +// published into. The skills directory is created on demand, so a host that +// does not have one yet falls back to its resolved home directory, which still +// collapses an aliased home before its first publication. A path that cannot be +// resolved at all is its own identity, which simply leaves the host uncollapsed. +async function resolveManagedSkillsDirectoryIdentity( + homeDirectory: string, +): Promise { + const skillsDirectoryPath = resolveManagedSkillsDirectoryPath(homeDirectory); + const resolvedSkillsDirectoryPath = await readRealPath(skillsDirectoryPath); + + if (resolvedSkillsDirectoryPath !== undefined) { + return resolvedSkillsDirectoryPath; + } + + const resolvedHomeDirectory = await readRealPath(homeDirectory); + + return resolvedHomeDirectory === undefined + ? skillsDirectoryPath + : resolveManagedSkillsDirectoryPath(resolvedHomeDirectory); +} + +async function readRealPath(path: string): Promise { + try { + return await realpath(path); + } + catch { + // Missing, inaccessible, or otherwise unresolvable paths carry no + // identity to compare; the caller falls back to the literal path. + return undefined; + } } export function resolveManagedSkillHostInstallation( From 38edc6c178ca1bc6a6119fc57ba772b9c620fcbc Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Tue, 11 Aug 2026 04:05:21 -0400 Subject: [PATCH 2/2] test(skills): extract the repeated registry and host setup The registry fetcher mock and the managed-host temporary directory setup were each written out twice, and the project's test guidance puts repeated setup in a local factory at the bottom of the file. The fetcher factory takes an optional request sink so the one test that asserts the request sequence keeps doing so. Signed-off-by: Kevin Cui --- src/application/commands/skills/index.test.ts | 105 +++++++----------- .../skills/managed-skill-hosts.test.ts | 58 ++++++---- 2 files changed, 79 insertions(+), 84 deletions(-) diff --git a/src/application/commands/skills/index.test.ts b/src/application/commands/skills/index.test.ts index 807ed9fe..75fe2410 100644 --- a/src/application/commands/skills/index.test.ts +++ b/src/application/commands/skills/index.test.ts @@ -352,39 +352,7 @@ describe("skills commands", () => { const result = await sandbox.run( ["skills", "install", "oo@0.0.2"], - { - fetcher: async (input, init) => { - const request = toRequest(input, init); - - requests.push(request); - - if (request.url.includes("/package-info/")) { - return new Response(JSON.stringify({ - packageName: "oo", - version: "0.0.2", - skills: [ - { - description: "Run OO workflows", - name: "runtime", - title: "Runtime", - }, - ], - })); - } - - if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) { - return new Response(await createRegistrySkillArchiveBytes({ - "package/package/skills/runtime/SKILL.md": "# Runtime\n", - })); - } - - if (isRegistryPackageDownloadCountRequest(request)) { - return new Response(null, { status: 204 }); - } - - throw new Error(`Unexpected request: ${request.url}`); - }, - }, + { fetcher: createRuntimeRegistryFetcher(requests) }, ); expect(result.exitCode).toBe(0); @@ -429,37 +397,7 @@ describe("skills commands", () => { const result = await sandbox.run( ["skills", "install", "oo@0.0.2"], - { - fetcher: async (input, init) => { - const request = toRequest(input, init); - - if (request.url.includes("/package-info/")) { - return new Response(JSON.stringify({ - packageName: "oo", - version: "0.0.2", - skills: [ - { - description: "Run OO workflows", - name: "runtime", - title: "Runtime", - }, - ], - })); - } - - if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) { - return new Response(await createRegistrySkillArchiveBytes({ - "package/package/skills/runtime/SKILL.md": "# Runtime\n", - })); - } - - if (isRegistryPackageDownloadCountRequest(request)) { - return new Response(null, { status: 204 }); - } - - throw new Error(`Unexpected request: ${request.url}`); - }, - }, + { fetcher: createRuntimeRegistryFetcher() }, ); // Both homes publish into the same directory: publishing them as two @@ -3899,6 +3837,45 @@ function isRegistryPackageDownloadCountRequest(request: Request): boolean { && request.url.endsWith("/download-count"); } +// Serves the `oo@0.0.2` registry package that publishes a single `runtime` +// skill. Pass a request sink to assert the request sequence. +function createRuntimeRegistryFetcher(requests?: Request[]): ( + input: string | URL | Request, + init?: RequestInit, +) => Promise { + return async (input, init) => { + const request = toRequest(input, init); + + requests?.push(request); + + if (request.url.includes("/package-info/")) { + return new Response(JSON.stringify({ + packageName: "oo", + version: "0.0.2", + skills: [ + { + description: "Run OO workflows", + name: "runtime", + title: "Runtime", + }, + ], + })); + } + + if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) { + return new Response(await createRegistrySkillArchiveBytes({ + "package/package/skills/runtime/SKILL.md": "# Runtime\n", + })); + } + + if (isRegistryPackageDownloadCountRequest(request)) { + return new Response(null, { status: 204 }); + } + + throw new Error(`Unexpected request: ${request.url}`); + }; +} + function createMultiPackageRegistryFetcher(): ( input: string | URL | Request, init?: RequestInit, diff --git a/src/application/commands/skills/managed-skill-hosts.test.ts b/src/application/commands/skills/managed-skill-hosts.test.ts index dfc01d27..94c5c6a2 100644 --- a/src/application/commands/skills/managed-skill-hosts.test.ts +++ b/src/application/commands/skills/managed-skill-hosts.test.ts @@ -9,28 +9,28 @@ import { resolveAvailableManagedSkillHosts } from "./managed-skill-hosts.ts"; describe("resolveAvailableManagedSkillHosts", () => { test("always includes the universal host even when its home directory does not exist", async () => { - const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); - const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + const hostSandbox = await createManagedSkillHostSandbox(); + const rootDirectory = hostSandbox.rootDirectory; try { - const hosts = await resolveAvailableManagedSkillHosts(env); + const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env); expect(hosts.map(host => host.agentName)).toEqual(["universal"]); expect(hosts[0]!.homeDirectory).toBe(join(rootDirectory, ".agents")); } finally { - await rm(rootDirectory, { force: true, recursive: true }); + await hostSandbox.cleanup(); } }); test("includes non-always-provision hosts only when their home directory exists", async () => { - const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); - const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + const hostSandbox = await createManagedSkillHostSandbox(); + const rootDirectory = hostSandbox.rootDirectory; try { await mkdir(join(rootDirectory, ".claude"), { recursive: true }); - const hosts = await resolveAvailableManagedSkillHosts(env); + const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env); const agentNames = hosts.map(host => host.agentName); // universal is always provisioned; claude is included because its home @@ -40,13 +40,13 @@ describe("resolveAvailableManagedSkillHosts", () => { expect(agentNames).not.toContain("hermes"); } finally { - await rm(rootDirectory, { force: true, recursive: true }); + await hostSandbox.cleanup(); } }); test("collapses hosts whose skills directories resolve to the same path", async () => { - const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); - const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + const hostSandbox = await createManagedSkillHostSandbox(); + const rootDirectory = hostSandbox.rootDirectory; try { const sharedSkillsDirectory = join(rootDirectory, ".shared", "skills"); @@ -63,7 +63,7 @@ describe("resolveAvailableManagedSkillHosts", () => { join(rootDirectory, ".claude", "skills"), ); - const hosts = await resolveAvailableManagedSkillHosts(env); + const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env); // Both homes publish into the same directory, so they are one host; // the universal fallback yields to the concrete agent. @@ -71,13 +71,13 @@ describe("resolveAvailableManagedSkillHosts", () => { expect(hosts[0]!.homeDirectory).toBe(join(rootDirectory, ".claude")); } finally { - await rm(rootDirectory, { force: true, recursive: true }); + await hostSandbox.cleanup(); } }); test("keeps the first concrete agent when two concrete agents share a skills directory", async () => { - const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); - const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + const hostSandbox = await createManagedSkillHostSandbox(); + const rootDirectory = hostSandbox.rootDirectory; try { const claudeSkillsDirectory = join(rootDirectory, ".claude", "skills"); @@ -89,20 +89,20 @@ describe("resolveAvailableManagedSkillHosts", () => { join(rootDirectory, ".trae-cn", "skills"), ); - const hosts = await resolveAvailableManagedSkillHosts(env); + const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env); // The universal host has its own (still missing) skills directory, so // only the two aliased concrete agents collapse. expect(hosts.map(host => host.agentName)).toEqual(["universal", "claude"]); } finally { - await rm(rootDirectory, { force: true, recursive: true }); + await hostSandbox.cleanup(); } }); test("collapses hosts whose home directories are aliased before any skills directory exists", async () => { - const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); - const env = { HOME: rootDirectory, USERPROFILE: rootDirectory }; + const hostSandbox = await createManagedSkillHostSandbox(); + const rootDirectory = hostSandbox.rootDirectory; try { await mkdir(join(rootDirectory, ".agents"), { recursive: true }); @@ -111,12 +111,30 @@ describe("resolveAvailableManagedSkillHosts", () => { join(rootDirectory, ".claude"), ); - const hosts = await resolveAvailableManagedSkillHosts(env); + const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env); expect(hosts.map(host => host.agentName)).toEqual(["claude"]); } finally { - await rm(rootDirectory, { force: true, recursive: true }); + await hostSandbox.cleanup(); } }); }); + +// Every host test needs an empty home directory tree plus the environment that +// points home resolution at it, and must remove the tree afterwards. +async function createManagedSkillHostSandbox(): Promise<{ + cleanup: () => Promise; + env: Record; + rootDirectory: string; +}> { + const rootDirectory = await createTemporaryDirectory("oo-managed-hosts"); + + return { + cleanup: async () => { + await rm(rootDirectory, { force: true, recursive: true }); + }, + env: { HOME: rootDirectory, USERPROFILE: rootDirectory }, + rootDirectory, + }; +}