skillrepo 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Platform conventions — single source of truth for OS-specific
3
+ * differences the CLI has to honor.
4
+ *
5
+ * The CLI runs on POSIX systems (macOS, Linux) and Windows. Most
6
+ * code paths are platform-neutral via Node built-ins (path.join,
7
+ * os.homedir, fs.rmSync, etc.) — but a handful of surfaces have
8
+ * real platform differences that can't be abstracted away at the
9
+ * Node level:
10
+ *
11
+ * 1. **Binary-locator command**. POSIX provides `which`; Windows
12
+ * provides `where.exe`. `execFileSync` doesn't spawn a shell,
13
+ * so the literal name must exist on disk.
14
+ *
15
+ * 2. **Hook shell backstop suffix**. The SessionStart hook command
16
+ * relies on a shell-level fallback (`|| true`) to guarantee
17
+ * exit 0 even if the binary vanishes. POSIX shells support it;
18
+ * cmd.exe doesn't know the `true` builtin and would emit a
19
+ * confusing error. The `--session-hook` flag's exit-0 contract
20
+ * inside the Node process is the primary defense regardless of
21
+ * platform; the shell backstop is belt-and-suspenders that we
22
+ * lose on Windows.
23
+ *
24
+ * 3. **POSIX file permissions**. `chmodSync(0o600)` silently
25
+ * succeeds on Windows but doesn't produce the intended effect —
26
+ * Windows's ACL model doesn't map to the Unix mode bits. Any
27
+ * call meant to restrict permissions on credential files must
28
+ * be guarded so Windows users aren't misled into thinking their
29
+ * files are access-controlled when they aren't.
30
+ *
31
+ * 4. **Atomic directory replacement semantics**. POSIX's
32
+ * `renameSync` over an existing directory is atomic on the same
33
+ * filesystem — the swap is instantaneous from the perspective
34
+ * of any concurrent reader. Windows fails with EEXIST/EPERM if
35
+ * the target exists; the replacement must be done as a
36
+ * remove-then-rename sequence with a small window where the
37
+ * target is missing. Callers that write skills have to know
38
+ * which strategy applies so they can surface a meaningful
39
+ * recovery hint if the Windows path fails mid-sequence.
40
+ *
41
+ * This module exposes a single `platformConventions()` function that
42
+ * returns a frozen object with every platform-specific value the
43
+ * CLI needs. New platform-specific surfaces should be added here
44
+ * rather than spreading ad-hoc `platform() === "win32"` checks
45
+ * across the codebase. This is a convention, not an enforced rule —
46
+ * it's documentation plus a consumer pattern, not a linter.
47
+ *
48
+ * The `platform` parameter is an optional override for tests —
49
+ * production callers let it default to `os.platform()`.
50
+ */
51
+
52
+ import { platform as osPlatform } from "node:os";
53
+
54
+ /**
55
+ * @typedef {Object} PlatformConventions
56
+ * @property {"posix" | "windows"} family - High-level family name.
57
+ * @property {string} binaryLocator - Command used to resolve a
58
+ * binary's absolute path from PATH. `"which"` on POSIX,
59
+ * `"where"` on Windows.
60
+ * @property {string} hookShellSuffix - Suffix appended to hook
61
+ * commands to guarantee exit 0 at the shell level. `" || true"`
62
+ * on POSIX (appended to the base command), empty string on
63
+ * Windows (the `--session-hook` flag's exit-0 contract is
64
+ * the only defense).
65
+ * @property {boolean} supportsPosixPermissions - True when
66
+ * `chmodSync(mode)` produces the intended POSIX mode-bit
67
+ * effect. False on Windows, where the call nominally
68
+ * succeeds but doesn't restrict ACLs the way a 0600 bit
69
+ * would on Unix. Callers use this to skip chmod on
70
+ * Windows rather than leave misleading "perms applied"
71
+ * success paths that don't actually restrict access.
72
+ * @property {boolean} supportsAtomicDirectoryRename - True when
73
+ * `renameSync` over an existing directory is atomic.
74
+ * POSIX: true. Windows: false — callers must implement
75
+ * remove-then-rename, accepting the small non-atomic
76
+ * window where the target doesn't exist. The Windows
77
+ * code path MUST produce a recoverable failure state if
78
+ * the rename step fails (i.e. leave the `.tmp/` dir on
79
+ * disk so the user can rename it manually).
80
+ */
81
+
82
+ const POSIX = Object.freeze({
83
+ family: "posix",
84
+ binaryLocator: "which",
85
+ hookShellSuffix: " || true",
86
+ supportsPosixPermissions: true,
87
+ supportsAtomicDirectoryRename: true,
88
+ });
89
+
90
+ const WINDOWS = Object.freeze({
91
+ family: "windows",
92
+ binaryLocator: "where",
93
+ hookShellSuffix: "",
94
+ supportsPosixPermissions: false,
95
+ supportsAtomicDirectoryRename: false,
96
+ });
97
+
98
+ /**
99
+ * Return the platform-specific convention set for the current
100
+ * platform, or an override.
101
+ *
102
+ * @param {object} [options]
103
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
104
+ * Production callers should let this default to the real
105
+ * runtime platform.
106
+ * @returns {PlatformConventions}
107
+ */
108
+ export function platformConventions({ platform: platformOverride } = {}) {
109
+ const plat = platformOverride ?? osPlatform();
110
+ return plat === "win32" ? WINDOWS : POSIX;
111
+ }
112
+
113
+ /**
114
+ * True if the current (or overridden) platform is Windows.
115
+ * Convenience wrapper — prefer `platformConventions().family ===
116
+ * "windows"` in call sites that already hold a conventions object.
117
+ *
118
+ * @param {object} [options]
119
+ * @param {NodeJS.Platform} [options.platform]
120
+ * @returns {boolean}
121
+ */
122
+ export function isWindows({ platform: platformOverride } = {}) {
123
+ return platformConventions({ platform: platformOverride }).family === "windows";
124
+ }
package/src/lib/sync.mjs CHANGED
@@ -27,6 +27,22 @@
27
27
  * @property {number} updated - Skills overwritten on disk
28
28
  * @property {number} removed - Tombstones applied
29
29
  * @property {boolean} notModified - True if 304 short-circuit fired
30
+ * @property {boolean | null} fullSync - True if this was a full (non-delta)
31
+ * sync — i.e. no prior `.last-sync` state
32
+ * existed so no `since` was sent. False if
33
+ * this was a delta sync. `null` only ever
34
+ * appears in SYNTHESIZED failure summaries
35
+ * (e.g. `init.mjs` when runSync threw) to
36
+ * mark the state as genuinely unknown —
37
+ * consumers should never see `null` from
38
+ * runSync itself. A full sync returning
39
+ * zero skills genuinely means "empty
40
+ * library"; a delta sync returning zero
41
+ * means "nothing changed since last sync".
42
+ * Consumers must distinguish these to
43
+ * render accurate user messages (see
44
+ * init.mjs step 7).
45
+ * @property {string} [syncedAt]
30
46
  * @property {string} syncedAt - ISO timestamp from the server response
31
47
  *
32
48
  * @typedef {Object} SyncStateFile
@@ -182,6 +198,14 @@ export async function runSync(options) {
182
198
  if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
183
199
  if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
184
200
 
201
+ // Track whether this is a full or delta sync BEFORE the network
202
+ // call, for the returned summary's `fullSync` field. A "full" sync
203
+ // is one where no `since` was sent — which is exactly when no
204
+ // prior last-sync state existed. The distinction matters to
205
+ // consumers (init.mjs) that need to tell "empty library" from
206
+ // "nothing changed since last sync" in the zero-counters case.
207
+ const fullSync = !lastSync?.syncedAt;
208
+
185
209
  const result = await getLibrary(serverUrl, apiKey, opts);
186
210
 
187
211
  // Step 4: 304 short-circuit
@@ -191,6 +215,7 @@ export async function runSync(options) {
191
215
  updated: 0,
192
216
  removed: 0,
193
217
  notModified: true,
218
+ fullSync,
194
219
  syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
195
220
  };
196
221
  }
@@ -201,6 +226,7 @@ export async function runSync(options) {
201
226
  updated: 0,
202
227
  removed: 0,
203
228
  notModified: false,
229
+ fullSync,
204
230
  syncedAt: result.syncedAt,
205
231
  };
206
232
 
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Transient package-runner detection (#894 / v3.1.2).
3
+ *
4
+ * The CLI cares about transient package runners — npx, pnpm dlx,
5
+ * yarn berry dlx, bunx — in two distinct ways:
6
+ *
7
+ * 1. **Detect when the current process IS a transient invocation**
8
+ * (`detectTransientRunner` / `isTransientRunnerInvocation`).
9
+ * Used by `init` to gate the auto-install-global flow and to
10
+ * pick the right runner-prefix in Next-Steps output, and by
11
+ * `mergers/session-hook` to refuse baking a transient cache
12
+ * path into a long-lived hook command.
13
+ *
14
+ * 2. **Detect when a candidate filesystem path is INSIDE a runner's
15
+ * transient cache** (`isTransientCachePath`). Used by the binary
16
+ * locator (`lib/binary-locator.mjs`) to filter `where`/`which`
17
+ * output so a cache-located binary doesn't shadow a real global
18
+ * install.
19
+ *
20
+ * The substring patterns for each runner's cache are the same data
21
+ * for both use cases; centralizing here is the single-source-of-truth
22
+ * fix for the duplication that existed in v3.1.2's first cleanup
23
+ * pass (cli-config.mjs and global-install.mjs each carried their own
24
+ * frozen array of the same substrings).
25
+ *
26
+ * ## Adding a new runner
27
+ *
28
+ * Add an entry to `TRANSIENT_RUNNERS` with:
29
+ * - `name`: the canonical runner-command string used in Next-Steps
30
+ * output (e.g. `"npx"`, `"pnpx"`, `"yarn dlx"`, `"bunx"`).
31
+ * - `cacheSubstrings`: substrings (POSIX and Windows separator
32
+ * variants) that uniquely identify the runner's per-invocation
33
+ * cache directory inside an absolute path. Both `argv[1]`-style
34
+ * paths and `where`/`which` output paths are matched against
35
+ * this list.
36
+ * - `commandSuffixes`: launcher-binary names that, when set as
37
+ * `process.env._` (the shell's "last command" var), uniquely
38
+ * identify this runner. Suffix-matched. Empty array if the
39
+ * runner has no canonical `_` form (e.g. `yarn dlx` with a
40
+ * space — `_` only gets the binary name, not the full command).
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} TransientRunner
45
+ * @property {string} name - Display name used in user-facing output
46
+ * (also the prefix for `<name> skillrepo list` next-step
47
+ * hints).
48
+ * @property {readonly string[]} cacheSubstrings - Path substrings
49
+ * that uniquely identify the runner's cache directory.
50
+ * @property {readonly string[]} commandSuffixes - `_` env-var
51
+ * suffix patterns matching the launcher binary name.
52
+ * @property {string} globalInstallCommand - The canonical "install
53
+ * skillrepo globally with this package manager" command
54
+ * we suggest to a user who just ran via this runner.
55
+ * Used by init's Next-Steps Tip when no global is yet
56
+ * active. Yarn berry uses `npm install -g` because yarn
57
+ * berry intentionally has no `yarn global add` equivalent
58
+ * (it directs users to `dlx` for one-offs and away from
59
+ * globals); npm-installed binaries land on the user's
60
+ * PATH the same regardless of which runner they used to
61
+ * bootstrap.
62
+ */
63
+
64
+ /**
65
+ * Catalog of supported transient runners. Order matters only for
66
+ * tie-breaking when multiple runners' substrings match the same
67
+ * path (extremely unlikely given the specificity of each); the
68
+ * first match wins.
69
+ */
70
+ export const TRANSIENT_RUNNERS = Object.freeze([
71
+ Object.freeze({
72
+ name: "npx",
73
+ // npx writes to `~/.npm/_npx/<hash>/...`.
74
+ cacheSubstrings: Object.freeze(["/_npx/", "\\_npx\\"]),
75
+ commandSuffixes: Object.freeze(["/npx", "\\npx"]),
76
+ globalInstallCommand: "npm install -g skillrepo",
77
+ }),
78
+ Object.freeze({
79
+ name: "pnpx",
80
+ // pnpm dlx writes to `<store>/dlx-<hash>/...`. Both pnpm dlx
81
+ // and the legacy `pnpx` shim hit this cache.
82
+ cacheSubstrings: Object.freeze(["/dlx-", "\\dlx-"]),
83
+ commandSuffixes: Object.freeze(["/pnpx", "\\pnpx"]),
84
+ globalInstallCommand: "pnpm add -g skillrepo",
85
+ }),
86
+ Object.freeze({
87
+ name: "yarn dlx",
88
+ // yarn berry dlx caches under `.yarn/berry/cache/...` and
89
+ // resolves PnP virtuals under `.yarn/$$virtual/...`. The
90
+ // launcher is `yarn dlx <pkg>` which sets `_` to `yarn`, NOT
91
+ // `yarn dlx` — so commandSuffixes is empty; argv-path detection
92
+ // is the only signal.
93
+ cacheSubstrings: Object.freeze([
94
+ "/.yarn/berry/",
95
+ "\\.yarn\\berry\\",
96
+ "/.yarn/$$virtual/",
97
+ "\\.yarn\\$$virtual\\",
98
+ ]),
99
+ commandSuffixes: Object.freeze([]),
100
+ // Yarn berry deliberately doesn't ship a `yarn global add`
101
+ // (the team directs users away from globals toward `yarn dlx`
102
+ // for one-offs). For users who DO want a persistent global,
103
+ // `npm install -g` is the universal fallback that works
104
+ // alongside yarn berry without conflict.
105
+ globalInstallCommand: "npm install -g skillrepo",
106
+ }),
107
+ Object.freeze({
108
+ name: "bunx",
109
+ cacheSubstrings: Object.freeze([
110
+ "/.bun/install/cache/",
111
+ "\\.bun\\install\\cache\\",
112
+ ]),
113
+ commandSuffixes: Object.freeze(["/bunx", "\\bunx"]),
114
+ globalInstallCommand: "bun add -g skillrepo",
115
+ }),
116
+ ]);
117
+
118
+ /**
119
+ * Look up the canonical global-install command for a runner display
120
+ * name (as returned by `detectTransientRunner`). Returns null when
121
+ * the name doesn't match any registered runner — caller should fall
122
+ * back to the universal `npm install -g skillrepo` text.
123
+ *
124
+ * @param {string | null} runnerName
125
+ * @returns {string | null}
126
+ */
127
+ export function globalInstallCommandFor(runnerName) {
128
+ if (!runnerName) return null;
129
+ const runner = TRANSIENT_RUNNERS.find((r) => r.name === runnerName);
130
+ return runner ? runner.globalInstallCommand : null;
131
+ }
132
+
133
+ /**
134
+ * Identify the transient runner that launched the current process,
135
+ * if any.
136
+ *
137
+ * Detection signals (first match wins):
138
+ * 1. `process.argv[1]` contains one of the runner's
139
+ * `cacheSubstrings`. The executable's path itself names the
140
+ * runner's cache directory.
141
+ * 2. `process.env._` ends with one of the runner's
142
+ * `commandSuffixes`. Defensive against shim layouts where
143
+ * `argv[1]` has been symlinked through a path that doesn't
144
+ * contain the cache substring.
145
+ *
146
+ * Why NOT `process.env.npm_command === "exec"`: that signal also
147
+ * fires for stable-install users running `npm exec skillrepo ...`
148
+ * directly or via a `package.json` lifecycle script. Adding it
149
+ * trades a minor coverage gain for a real false-positive surface
150
+ * affecting users with a real global install.
151
+ *
152
+ * @param {object} [options]
153
+ * @param {string[]} [options.argv=process.argv] - Test override.
154
+ * @param {NodeJS.ProcessEnv} [options.env=process.env] - Test override.
155
+ * @returns {string | null} The runner's display name, or `null` if
156
+ * the process is NOT a transient invocation.
157
+ */
158
+ export function detectTransientRunner({
159
+ argv = process.argv,
160
+ env = process.env,
161
+ } = {}) {
162
+ const execPath = argv[1] ?? "";
163
+ for (const runner of TRANSIENT_RUNNERS) {
164
+ for (const substring of runner.cacheSubstrings) {
165
+ if (execPath.includes(substring)) return runner.name;
166
+ }
167
+ }
168
+ const underscore = env._ ?? "";
169
+ for (const runner of TRANSIENT_RUNNERS) {
170
+ for (const suffix of runner.commandSuffixes) {
171
+ if (underscore.endsWith(suffix)) return runner.name;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Boolean shortcut over `detectTransientRunner`. Use when a caller
179
+ * only needs to know "is this a transient invocation at all?" and
180
+ * doesn't care which runner.
181
+ *
182
+ * @returns {boolean}
183
+ */
184
+ export function isTransientRunnerInvocation() {
185
+ return detectTransientRunner() !== null;
186
+ }
187
+
188
+ /**
189
+ * True when `absPath` is inside any registered runner's transient
190
+ * cache directory. Used by the binary locator to filter
191
+ * `where`/`which` output so a cache-located binary doesn't get
192
+ * baked into a long-lived hook command.
193
+ *
194
+ * @param {string} absPath - An absolute filesystem path.
195
+ * @returns {boolean}
196
+ */
197
+ export function isTransientCachePath(absPath) {
198
+ for (const runner of TRANSIENT_RUNNERS) {
199
+ for (const substring of runner.cacheSubstrings) {
200
+ if (absPath.includes(substring)) return true;
201
+ }
202
+ }
203
+ return false;
204
+ }
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -46,9 +52,9 @@ async function setup() {
46
52
  mkdirSync(join(sandbox, "project"), { recursive: true });
47
53
  mkdirSync(join(sandbox, "home"), { recursive: true });
48
54
  originalCwd = process.cwd();
49
- originalHome = process.env.HOME;
55
+ originalHomeEnv = captureHome();
50
56
  process.chdir(join(sandbox, "project"));
51
- process.env.HOME = join(sandbox, "home");
57
+ setSandboxHome(join(sandbox, "home"));
52
58
  delete process.env.SKILLREPO_ACCESS_KEY;
53
59
 
54
60
  server = createMockServer({});
@@ -61,7 +67,7 @@ async function setup() {
61
67
  async function teardown() {
62
68
  if (server) await server.stop();
63
69
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
70
+ restoreHome(originalHomeEnv);
65
71
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
72
  server = null;
67
73
  }
@@ -13,12 +13,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -53,9 +59,9 @@ async function setup() {
53
59
  mkdirSync(join(sandbox, "project"), { recursive: true });
54
60
  mkdirSync(join(sandbox, "home"), { recursive: true });
55
61
  originalCwd = process.cwd();
56
- originalHome = process.env.HOME;
62
+ originalHomeEnv = captureHome();
57
63
  process.chdir(join(sandbox, "project"));
58
- process.env.HOME = join(sandbox, "home");
64
+ setSandboxHome(join(sandbox, "home"));
59
65
  delete process.env.SKILLREPO_ACCESS_KEY;
60
66
  delete process.env.SKILLREPO_URL;
61
67
 
@@ -69,7 +75,7 @@ async function setup() {
69
75
  async function teardown() {
70
76
  if (server) await server.stop();
71
77
  process.chdir(originalCwd);
72
- process.env.HOME = originalHome;
78
+ restoreHome(originalHomeEnv);
73
79
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
74
80
  server = null;
75
81
  }