bundled.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import { existsSync } from "node:fs";
  2. import { readdir, stat } from "node:fs/promises";
  3. import { join, relative } from "node:path";
  4. // --- Types ---
  5. /** The category of a bundled file, inferred from its path prefix. */
  6. export type BundledFileType = "agent" | "context" | "skill" | "config";
  7. // --- Constants ---
  8. /** Subdirectories under the package root that contain bundled OAC files. */
  9. const BUNDLED_SUBDIRS = [
  10. ".opencode/agent",
  11. ".opencode/context",
  12. ".opencode/skills",
  13. ] as const;
  14. // --- Package root resolution ---
  15. /**
  16. * Walks up the directory tree from `startDir` until it finds a directory
  17. * that contains both `.opencode/` and `package.json` — the npm package root.
  18. *
  19. * Works in both development (monorepo) and when installed via npm.
  20. * import.meta.dir is Bun's native equivalent of __dirname.
  21. */
  22. export function getPackageRoot(): string {
  23. // Allow dev/monorepo override via environment variable.
  24. // In production (npm install), OAC_PACKAGE_ROOT is not set so the walk runs as before.
  25. // In dev, set OAC_PACKAGE_ROOT=/path/to/repo to bypass the walk entirely.
  26. const envOverride = process.env['OAC_PACKAGE_ROOT'];
  27. if (envOverride) {
  28. return envOverride;
  29. }
  30. // import.meta.dir is Bun's native equivalent of __dirname — points to packages/cli/dist/ at runtime
  31. return findPackageRoot(import.meta.dir);
  32. }
  33. /**
  34. * Synchronously walks up from `dir` until finding a directory that has
  35. * all three anchors:
  36. * 1. `.opencode/` — OAC configuration directory
  37. * 2. `package.json` — npm package manifest
  38. * 3. No `registry.json` at the same level — `registry.json` is present at
  39. * the monorepo root but NOT at the CLI package root, so its absence
  40. * distinguishes the CLI package from the repo root in a monorepo layout.
  41. *
  42. * Throws if the filesystem root is reached without finding a match.
  43. *
  44. * Pure in intent — no side effects beyond filesystem reads.
  45. */
  46. export function findPackageRoot(dir: string): string {
  47. let current = dir;
  48. while (true) {
  49. const hasOpencode = existsSync(join(current, ".opencode"));
  50. const hasPackageJson = existsSync(join(current, "package.json"));
  51. // registry.json exists at the monorepo root but NOT at the CLI package root.
  52. // Excluding directories that have it prevents the walk from stopping at the
  53. // repo root instead of the actual CLI package root.
  54. const hasRegistryJson = existsSync(join(current, "registry.json"));
  55. if (hasOpencode && hasPackageJson && !hasRegistryJson) {
  56. return current;
  57. }
  58. const parent = join(current, "..");
  59. // Reached filesystem root — no package root found
  60. if (parent === current) {
  61. throw new Error(
  62. `getPackageRoot: could not find a directory with ".opencode/" and "package.json" ` +
  63. `(without a "registry.json" at the same level) walking up from "${dir}". ` +
  64. `Is @nextsystems/oac installed correctly? ` +
  65. `In dev/monorepo mode, set OAC_PACKAGE_ROOT env var to the repo root.`,
  66. );
  67. }
  68. current = parent;
  69. }
  70. }
  71. // --- Path helpers ---
  72. /**
  73. * Returns the absolute path to a bundled file given the package root and a
  74. * relative path (e.g. `.opencode/agent/core/openagent.md`).
  75. *
  76. * Pure function — no I/O.
  77. */
  78. export const getBundledFilePath = (
  79. packageRoot: string,
  80. relativePath: string,
  81. ): string => join(packageRoot, relativePath);
  82. // --- File enumeration ---
  83. /**
  84. * Recursively collects all file paths under `dir`, returning them as
  85. * absolute paths. Directories are not included in the result.
  86. */
  87. async function collectFiles(dir: string): Promise<string[]> {
  88. const entries = await readdir(dir, { withFileTypes: true });
  89. const nested = await Promise.all(
  90. entries.map((entry) => {
  91. const fullPath = join(dir, entry.name);
  92. return entry.isDirectory() ? collectFiles(fullPath) : Promise.resolve([fullPath]);
  93. }),
  94. );
  95. return nested.flat();
  96. }
  97. /**
  98. * Lists all files under `.opencode/agent/`, `.opencode/context/`, and
  99. * `.opencode/skills/` within the given package root.
  100. *
  101. * Returns relative paths like `.opencode/agent/core/openagent.md`.
  102. * Subdirectories that do not exist are silently skipped.
  103. */
  104. export async function listBundledFiles(packageRoot: string): Promise<string[]> {
  105. const results = await Promise.all(
  106. BUNDLED_SUBDIRS.map(async (subdir) => {
  107. const absSubdir = join(packageRoot, subdir);
  108. const exists = await stat(absSubdir).then((s) => s.isDirectory()).catch(() => false);
  109. if (!exists) return [];
  110. const absFiles = await collectFiles(absSubdir);
  111. return absFiles.map((absFile) => relative(packageRoot, absFile));
  112. }),
  113. );
  114. return results.flat();
  115. }
  116. // --- Existence check ---
  117. /**
  118. * Returns true if the bundled file at `relativePath` exists within the
  119. * given package root.
  120. */
  121. export const bundledFileExists = async (
  122. packageRoot: string,
  123. relativePath: string,
  124. ): Promise<boolean> => Bun.file(getBundledFilePath(packageRoot, relativePath)).exists();
  125. // --- Classification ---
  126. /**
  127. * Infers the BundledFileType from a relative path prefix.
  128. *
  129. * - `.opencode/agent/...` → "agent"
  130. * - `.opencode/context/...` → "context"
  131. * - `.opencode/skills/...` → "skill"
  132. * - anything else → "config"
  133. *
  134. * Pure function — no I/O.
  135. */
  136. export const classifyBundledFile = (relativePath: string): BundledFileType => {
  137. if (relativePath.startsWith(".opencode/agent/")) return "agent";
  138. if (relativePath.startsWith(".opencode/context/")) return "context";
  139. if (relativePath.startsWith(".opencode/skills/")) return "skill";
  140. return "config";
  141. };