@uluops/setup 0.7.0 → 0.8.1

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 (58) hide show
  1. package/README.md +91 -9
  2. package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
  3. package/dist/cli/select-harnesses.d.ts +91 -0
  4. package/dist/cli/select-harnesses.js +108 -0
  5. package/dist/cli.js +78 -37
  6. package/dist/commands/errors.d.ts +24 -0
  7. package/dist/commands/errors.js +28 -0
  8. package/dist/commands/helpers.d.ts +7 -0
  9. package/dist/commands/helpers.js +80 -3
  10. package/dist/commands/per-harness.d.ts +64 -0
  11. package/dist/commands/per-harness.js +37 -0
  12. package/dist/commands/setup.d.ts +5 -3
  13. package/dist/commands/setup.js +174 -48
  14. package/dist/commands/uninstall-filter.d.ts +36 -0
  15. package/dist/commands/uninstall-filter.js +69 -0
  16. package/dist/commands/uninstall.d.ts +12 -2
  17. package/dist/commands/uninstall.js +121 -45
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +89 -21
  20. package/dist/harnesses/index.js +6 -1
  21. package/dist/harnesses/opencode.d.ts +8 -0
  22. package/dist/harnesses/opencode.js +24 -1
  23. package/dist/harnesses/types.d.ts +2 -0
  24. package/dist/harnesses/types.js +5 -1
  25. package/dist/lib/atomic-write.js +10 -2
  26. package/dist/lib/config-merger.d.ts +6 -3
  27. package/dist/lib/config-merger.js +50 -7
  28. package/dist/lib/display.d.ts +21 -5
  29. package/dist/lib/display.js +118 -13
  30. package/dist/lib/file-ops.d.ts +13 -5
  31. package/dist/lib/file-ops.js +34 -34
  32. package/dist/lib/install-lock.js +11 -1
  33. package/dist/lib/json-guards.d.ts +22 -0
  34. package/dist/lib/json-guards.js +33 -0
  35. package/dist/lib/manifest.d.ts +20 -0
  36. package/dist/lib/manifest.js +61 -12
  37. package/dist/lib/paths.d.ts +0 -17
  38. package/dist/lib/paths.js +0 -19
  39. package/dist/lib/settings-merger.js +3 -1
  40. package/dist/steps/agent-metrics-cli.d.ts +9 -1
  41. package/dist/steps/agent-metrics-cli.js +66 -20
  42. package/dist/steps/agents.d.ts +11 -0
  43. package/dist/steps/agents.js +30 -25
  44. package/dist/steps/auth.d.ts +13 -0
  45. package/dist/steps/auth.js +61 -5
  46. package/dist/steps/cli.d.ts +6 -0
  47. package/dist/steps/cli.js +29 -10
  48. package/dist/steps/commands.d.ts +10 -0
  49. package/dist/steps/commands.js +31 -30
  50. package/dist/steps/detect.js +15 -1
  51. package/dist/steps/mcp.js +1 -8
  52. package/dist/steps/metrics.js +10 -3
  53. package/dist/steps/shell.js +3 -13
  54. package/dist/steps/signup.js +14 -1
  55. package/dist/steps/skills.d.ts +14 -0
  56. package/dist/steps/skills.js +95 -0
  57. package/dist/steps/verify.js +195 -91
  58. package/package.json +3 -2
package/dist/steps/cli.js CHANGED
@@ -1,12 +1,37 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  export const CLI_PACKAGE = "@uluops/cli";
3
3
  export const CLI_BIN = "ulu";
4
+ /**
5
+ * Maximum time we wait for an `npm install -g` / `npm uninstall -g` to
6
+ * complete before terminating it. Bounded to protect setup from indefinite
7
+ * hangs caused by corporate proxy stalls, lifecycle scripts awaiting stdin,
8
+ * or registry slow-response storms. 5 minutes is generous for an npm install
9
+ * of a small CLI and tight enough that users notice and can intervene.
10
+ */
11
+ const NPM_TIMEOUT_MS = 5 * 60_000;
12
+ /** Short read; 30s is plenty for `ulu --version`. */
13
+ const DETECT_TIMEOUT_MS = 30_000;
14
+ /** @internal — exported for test access to the timeout-summarization branch. */
15
+ export function summarizeSpawnResult(r, op) {
16
+ if (r.status === 0)
17
+ return { ok: true };
18
+ if (r.signal === "SIGTERM" && r.status === null) {
19
+ return {
20
+ ok: false,
21
+ error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
22
+ };
23
+ }
24
+ const stderr = (r.stderr ?? "").toString().trim();
25
+ const stdout = (r.stdout ?? "").toString().trim();
26
+ return { ok: false, error: stderr || stdout || `exit ${r.status}` };
27
+ }
4
28
  /** Default executor — shells out to `ulu` and `npm`. */
5
29
  export const defaultExecutor = {
6
30
  detect: () => {
7
31
  const r = spawnSync(CLI_BIN, ["--version"], {
8
32
  encoding: "utf-8",
9
33
  stdio: ["ignore", "pipe", "ignore"],
34
+ timeout: DETECT_TIMEOUT_MS,
10
35
  });
11
36
  if (r.status !== 0 || !r.stdout)
12
37
  return null;
@@ -16,23 +41,17 @@ export const defaultExecutor = {
16
41
  const r = spawnSync("npm", ["install", "-g", CLI_PACKAGE], {
17
42
  encoding: "utf-8",
18
43
  stdio: ["ignore", "pipe", "pipe"],
44
+ timeout: NPM_TIMEOUT_MS,
19
45
  });
20
- if (r.status === 0)
21
- return { ok: true };
22
- const stderr = (r.stderr ?? "").toString().trim();
23
- const stdout = (r.stdout ?? "").toString().trim();
24
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
46
+ return summarizeSpawnResult(r, "install");
25
47
  },
26
48
  uninstall: () => {
27
49
  const r = spawnSync("npm", ["uninstall", "-g", CLI_PACKAGE], {
28
50
  encoding: "utf-8",
29
51
  stdio: ["ignore", "pipe", "pipe"],
52
+ timeout: NPM_TIMEOUT_MS,
30
53
  });
31
- if (r.status === 0)
32
- return { ok: true };
33
- const stderr = (r.stderr ?? "").toString().trim();
34
- const stdout = (r.stdout ?? "").toString().trim();
35
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
54
+ return summarizeSpawnResult(r, "uninstall");
36
55
  },
37
56
  };
38
57
  /**
@@ -7,6 +7,16 @@ export interface CommandsResult {
7
7
  removed: number;
8
8
  files: string[];
9
9
  skippedReason?: string;
10
+ /**
11
+ * Per-file copy failures across all subdirs. The loop continues past errors
12
+ * so a single bad file cannot abort the install and leave commands half-
13
+ * installed. The caller surfaces these via `warn()`. Files in this list are
14
+ * NOT counted in agent/workflow/pipeline counters (or `skipped`).
15
+ */
16
+ failures: {
17
+ file: string;
18
+ error: string;
19
+ }[];
10
20
  }
11
21
  /** Install pre-rendered command files from harness-specific assets. */
12
22
  export declare function installCommands(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestCommands?: string[]): Promise<CommandsResult>;
@@ -1,7 +1,7 @@
1
- import { readdir, mkdir, unlink } from "node:fs/promises";
1
+ import { readdir, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
- import { copyIfChanged, unlinkFiles } from "../lib/file-ops.js";
4
+ import { copyIfChanged, unlinkFiles, removeStaleFiles, } from "../lib/file-ops.js";
5
5
  const SUBDIRS = ["agents", "workflows", "pipelines"];
6
6
  /** Install pre-rendered command files from harness-specific assets. */
7
7
  export async function installCommands(profile, localDefs, dryRun, existingManifestCommands) {
@@ -27,6 +27,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
27
27
  removed: 0,
28
28
  files: [],
29
29
  skippedReason: "not-supported",
30
+ failures: [],
30
31
  };
31
32
  }
32
33
  let agentCommands = 0;
@@ -34,6 +35,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
34
35
  let pipelineCommands = 0;
35
36
  let skipped = 0;
36
37
  const allFiles = [];
38
+ const failures = [];
37
39
  for (const subdir of SUBDIRS) {
38
40
  const srcDir = join(srcBase, subdir);
39
41
  const destDir = join(destBase, subdir);
@@ -49,38 +51,36 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
49
51
  }
50
52
  for (const file of files) {
51
53
  const relativePath = `${subdir}/${file}`;
52
- const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
53
- if (result === "copied") {
54
- if (subdir === "agents")
55
- agentCommands++;
56
- else if (subdir === "workflows")
57
- workflowCommands++;
58
- else
59
- pipelineCommands++;
60
- }
61
- else {
62
- skipped++;
63
- }
64
- allFiles.push(relativePath);
65
- }
66
- }
67
- // Remove files that were in the old manifest but no longer in the package
68
- let removed = 0;
69
- if (existingManifestCommands) {
70
- for (const oldFile of existingManifestCommands) {
71
- if (!allFiles.includes(oldFile)) {
72
- if (!dryRun) {
73
- try {
74
- await unlink(join(destBase, oldFile));
75
- }
76
- catch {
77
- // Already gone
78
- }
54
+ try {
55
+ const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
56
+ if (result === "copied") {
57
+ if (subdir === "agents")
58
+ agentCommands++;
59
+ else if (subdir === "workflows")
60
+ workflowCommands++;
61
+ else
62
+ pipelineCommands++;
63
+ }
64
+ else {
65
+ skipped++;
79
66
  }
80
- removed++;
67
+ // Only track files that actually made it into a known state. Failed
68
+ // copies do NOT enter allFiles — otherwise the manifest's "remove
69
+ // stale entries on re-run" diff would treat a never-copied file as
70
+ // present, and an `--uninstall` would later try to unlink something
71
+ // that was never written.
72
+ allFiles.push(relativePath);
73
+ }
74
+ catch (err) {
75
+ // Continue past per-file failures (see AgentsResult.failures rationale).
76
+ failures.push({
77
+ file: relativePath,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ });
81
80
  }
82
81
  }
83
82
  }
83
+ const removed = await removeStaleFiles(destBase, existingManifestCommands, allFiles, dryRun);
84
84
  return {
85
85
  agentCommands,
86
86
  workflowCommands,
@@ -88,6 +88,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
88
88
  skipped,
89
89
  removed,
90
90
  files: allFiles,
91
+ failures,
91
92
  };
92
93
  }
93
94
  /** Remove previously installed command files by relative path. Returns count of successfully removed files. */
@@ -16,7 +16,21 @@ export async function detect() {
16
16
  const isWsl = os === "linux" && release().toLowerCase().includes("microsoft");
17
17
  const profile = getShellProfile();
18
18
  const nodeVersion = process.version;
19
- const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0] ?? "0", 10);
19
+ // Standard process.version is v-prefixed semver ("v20.10.0"). We take the
20
+ // major after stripping the "v". The `?? "0"` removed from the prior version
21
+ // here was dead code — `"".split(".")` returns `[""]`, not an empty array,
22
+ // so the nullish coalesce never fired and an unparseable major silently
23
+ // resulted in NaN, which then fell through the `< 20` gate.
24
+ const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0] ?? "", 10);
25
+ if (Number.isNaN(majorVersion)) {
26
+ // Defense in depth: a tampered binary, a wrapper that spoofs
27
+ // process.version, or a future Node release that breaks the v-prefixed
28
+ // semver convention would all silently pass the major-version gate.
29
+ // Reject with a distinct error so the user sees "your runtime is wrong"
30
+ // rather than the misleading "requires Node.js 20 or higher."
31
+ throw new Error(`Cannot parse Node.js version "${nodeVersion}". ` +
32
+ `@uluops/setup requires a standard v-prefixed semver (e.g., v20.10.0).`);
33
+ }
20
34
  if (majorVersion < 20) {
21
35
  throw new Error(`Unsupported Node.js version: ${nodeVersion}. @uluops/setup requires Node.js 20 or higher.`);
22
36
  }
package/dist/steps/mcp.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { readFile, access } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { checkMcpPackageAvailability } from "../lib/config-merger.js";
4
- import { findProjectRoot, getBackupDir } from "../lib/paths.js";
4
+ import { findProjectRoot } from "../lib/paths.js";
5
5
  import { atomicWrite } from "../lib/atomic-write.js";
6
- import { backupFile } from "../lib/file-ops.js";
7
6
  /** Write UluOps MCP server entries into a harness's config file. */
8
7
  export async function installMcp(profile, apiKey, scope, dryRun) {
9
8
  const configPath = scope === "global"
@@ -17,8 +16,6 @@ export async function installMcp(profile, apiKey, scope, dryRun) {
17
16
  packageWarnings.push(`npm packages not found in registry: ${missing.join(", ")}. MCP servers may fail to start.`);
18
17
  }
19
18
  if (!dryRun) {
20
- // Backup before first write
21
- await backupConfig(profile.name, configPath);
22
19
  await profile.mcpConfig.write(configPath, merged);
23
20
  }
24
21
  if (scope === "local" && !dryRun) {
@@ -28,14 +25,10 @@ export async function installMcp(profile, apiKey, scope, dryRun) {
28
25
  }
29
26
  /** Remove UluOps MCP server entries from the harness config. */
30
27
  export async function uninstallMcp(profile, configPath) {
31
- await backupConfig(profile.name, configPath);
32
28
  const config = await profile.mcpConfig.read(configPath);
33
29
  const cleaned = profile.mcpConfig.remove(config);
34
30
  await profile.mcpConfig.write(configPath, cleaned);
35
31
  }
36
- async function backupConfig(harnessName, configPath) {
37
- await backupFile(configPath, getBackupDir(harnessName));
38
- }
39
32
  async function addToGitignore(localConfigFilename) {
40
33
  const root = await findProjectRoot();
41
34
  try {
@@ -5,7 +5,8 @@
5
5
  * Only active for harnesses that support hooks (currently Claude Code only).
6
6
  */
7
7
  import { mkdir, readdir, copyFile, rm, access, readFile } from "node:fs/promises";
8
- import { join } from "node:path";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  /** Where agent-metrics dist files are installed (derived from profile) */
10
11
  function getMetricsToolDir(profile) {
11
12
  return profile.paths.toolsDir;
@@ -36,8 +37,14 @@ export function getHookCommand(profile) {
36
37
  async function findMetricsSource() {
37
38
  try {
38
39
  const resolved = import.meta.resolve("@uluops/agent-metrics");
39
- // resolved is like file:///path/to/dist/index.js — get the package root
40
- const distDir = new URL(".", resolved).pathname;
40
+ // resolved is a file:// URL like file:///path/to/dist/index.js. Convert
41
+ // to a filesystem path via fileURLToPath, which handles Windows drive
42
+ // letters correctly. The previous `.pathname` access produced
43
+ // `/C:/path/to/...` on Windows (and WSL-mounted Windows paths),
44
+ // breaking the subsequent readFile of package.json — silently returning
45
+ // a null version and defeating verify's drift detection.
46
+ const distFile = fileURLToPath(resolved);
47
+ const distDir = dirname(distFile);
41
48
  const pkgRoot = join(distDir, "..");
42
49
  const version = await readSourceVersion(pkgRoot);
43
50
  return { pkgRoot, version };
@@ -1,8 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
2
  import { atomicWrite } from "../lib/atomic-write.js";
4
- import { backupFile } from "../lib/file-ops.js";
5
- import { getUluopsDir } from "../lib/paths.js";
6
3
  const FENCE_START = "# --- UluOps (managed by @uluops/setup) ---";
7
4
  const FENCE_END = "# --- /UluOps ---";
8
5
  /** Characters safe for shell variable values (no metacharacters). */
@@ -25,21 +22,18 @@ export async function writeShellExport(profilePath, apiKey, dryRun) {
25
22
  }
26
23
  const startIdx = content.indexOf(FENCE_START);
27
24
  const endIdx = content.lastIndexOf(FENCE_END);
28
- if (!dryRun) {
29
- await backupProfile(profilePath);
30
- }
31
25
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
32
26
  // Replace existing fenced block — using lastIndexOf for FENCE_END collapses
33
27
  // any duplicate blocks left by earlier buggy installs into a single new block
34
28
  const before = content.slice(0, startIdx);
35
29
  const after = content.slice(endIdx + FENCE_END.length);
36
30
  if (!dryRun) {
37
- await atomicWrite(profilePath, before + block + after);
31
+ await atomicWrite(profilePath, before + block + after, { mode: 0o600 });
38
32
  }
39
33
  }
40
34
  else {
41
35
  if (!dryRun) {
42
- await atomicWrite(profilePath, content.trimEnd() + "\n\n" + block + "\n");
36
+ await atomicWrite(profilePath, content.trimEnd() + "\n\n" + block + "\n", { mode: 0o600 });
43
37
  }
44
38
  }
45
39
  }
@@ -55,12 +49,8 @@ export async function removeShellExport(profilePath) {
55
49
  const startIdx = content.indexOf(FENCE_START);
56
50
  const endIdx = content.lastIndexOf(FENCE_END);
57
51
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
58
- await backupProfile(profilePath);
59
52
  const before = content.slice(0, startIdx);
60
53
  const after = content.slice(endIdx + FENCE_END.length);
61
- await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"));
54
+ await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"), { mode: 0o600 });
62
55
  }
63
56
  }
64
- async function backupProfile(profilePath) {
65
- await backupFile(profilePath, join(getUluopsDir(), "backups", "shell"));
66
- }
@@ -50,6 +50,15 @@ export async function signup() {
50
50
  email: registerRes.data.user?.email ?? email,
51
51
  };
52
52
  }
53
+ /**
54
+ * Issue a POST and decode the response into `T` via a caller-supplied
55
+ * structural predicate. `validate` is required, not optional — making it
56
+ * optional in earlier revisions meant a call site could omit it and get
57
+ * back `body as T` with only a "not null, is object" check, which is the
58
+ * pattern type-safety-validator flagged (EPI-OVR/H). Every current call
59
+ * site supplies a real predicate; this signature change locks that in for
60
+ * future call sites.
61
+ */
53
62
  async function callApi(url, method, body, bearerToken, validate) {
54
63
  const headers = {
55
64
  "Content-Type": "application/json",
@@ -77,7 +86,11 @@ async function callApi(url, method, body, bearerToken, validate) {
77
86
  if (typeof body !== "object" || body === null) {
78
87
  throw new Error("Unexpected API response shape");
79
88
  }
80
- if (validate && !validate(body)) {
89
+ // `body as T` is a type assertion, not a runtime check — the validate
90
+ // predicate is what actually proves the structure. With validate now
91
+ // required, every code path passes through a real shape check before
92
+ // the cast is trusted.
93
+ if (!validate(body)) {
81
94
  throw new Error("API response failed structural validation");
82
95
  }
83
96
  return body;
@@ -0,0 +1,14 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
2
+ export interface SkillsResult {
3
+ copied: number;
4
+ skipped: number;
5
+ removed: number;
6
+ files: string[];
7
+ skippedReason?: string;
8
+ failures: {
9
+ file: string;
10
+ error: string;
11
+ }[];
12
+ }
13
+ export declare function installSkills(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestSkills?: string[]): Promise<SkillsResult>;
14
+ export declare function uninstallSkills(files: string[], defsPath: string): Promise<number>;
@@ -0,0 +1,95 @@
1
+ import { mkdir, readdir, rmdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
+ import { copyIfChanged, removeStaleFiles, unlinkFiles } from "../lib/file-ops.js";
5
+ async function listFilesRecursive(dir, prefix = "") {
6
+ let entries;
7
+ try {
8
+ entries = await readdir(dir, { withFileTypes: true });
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ const files = [];
14
+ for (const entry of entries) {
15
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
16
+ const full = join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ files.push(...await listFilesRecursive(full, rel));
19
+ }
20
+ else if (entry.isFile()) {
21
+ files.push(rel);
22
+ }
23
+ }
24
+ return files.sort((a, b) => a.localeCompare(b));
25
+ }
26
+ export async function installSkills(profile, localDefs, dryRun, existingManifestSkills) {
27
+ const srcBase = join(ASSETS_DIR, profile.name, "skills");
28
+ const destBase = localDefs
29
+ ? join(await findProjectRoot(), "uluops", "skills")
30
+ : profile.paths.skillsDir;
31
+ if (!destBase) {
32
+ return {
33
+ copied: 0,
34
+ skipped: 0,
35
+ removed: 0,
36
+ files: [],
37
+ skippedReason: "not-supported",
38
+ failures: [],
39
+ };
40
+ }
41
+ const files = await listFilesRecursive(srcBase);
42
+ if (files.length === 0) {
43
+ return {
44
+ copied: 0,
45
+ skipped: 0,
46
+ removed: 0,
47
+ files: [],
48
+ skippedReason: "not-supported",
49
+ failures: [],
50
+ };
51
+ }
52
+ let copied = 0;
53
+ let skipped = 0;
54
+ const installedFiles = [];
55
+ const failures = [];
56
+ for (const file of files) {
57
+ const src = join(srcBase, file);
58
+ const dest = join(destBase, file);
59
+ try {
60
+ if (!dryRun) {
61
+ await mkdir(dirname(dest), { recursive: true });
62
+ }
63
+ const result = await copyIfChanged(src, dest, dryRun);
64
+ if (result === "copied")
65
+ copied++;
66
+ else
67
+ skipped++;
68
+ installedFiles.push(file);
69
+ }
70
+ catch (err) {
71
+ failures.push({
72
+ file,
73
+ error: err instanceof Error ? err.message : String(err),
74
+ });
75
+ }
76
+ }
77
+ const removed = await removeStaleFiles(destBase, existingManifestSkills, installedFiles, dryRun);
78
+ return { copied, skipped, removed, files: installedFiles, failures };
79
+ }
80
+ export async function uninstallSkills(files, defsPath) {
81
+ const skillsDir = join(defsPath, "skills");
82
+ const removed = await unlinkFiles(skillsDir, files);
83
+ const skillDirs = new Set(files
84
+ .map((file) => file.split("/")[0])
85
+ .filter((dir) => typeof dir === "string" && dir.length > 0));
86
+ for (const dir of skillDirs) {
87
+ try {
88
+ await rmdir(join(skillsDir, dir));
89
+ }
90
+ catch {
91
+ // Already gone or non-empty due to user files.
92
+ }
93
+ }
94
+ return removed;
95
+ }