config-io.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /// <reference types="bun-types" />
  2. import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
  3. import {
  4. existsSync,
  5. mkdirSync,
  6. mkdtempSync,
  7. readFileSync,
  8. rmSync,
  9. writeFileSync,
  10. } from 'node:fs';
  11. import { tmpdir } from 'node:os';
  12. import { join } from 'node:path';
  13. import {
  14. addPluginToOpenCodeConfig,
  15. detectCurrentConfig,
  16. disableDefaultAgents,
  17. parseConfig,
  18. parseConfigFile,
  19. stripJsonComments,
  20. writeConfig,
  21. writeLiteConfig,
  22. } from './config-io';
  23. import * as paths from './paths';
  24. describe('config-io', () => {
  25. let tmpDir: string;
  26. const originalEnv = { ...process.env };
  27. const originalArgv = [...process.argv];
  28. beforeEach(() => {
  29. tmpDir = mkdtempSync(join(tmpdir(), 'opencode-io-test-'));
  30. delete process.env.OPENCODE_CONFIG_DIR;
  31. process.env.XDG_CONFIG_HOME = tmpDir;
  32. });
  33. afterEach(() => {
  34. process.env = { ...originalEnv };
  35. process.argv = [...originalArgv];
  36. if (tmpDir && existsSync(tmpDir)) {
  37. rmSync(tmpDir, { recursive: true, force: true });
  38. }
  39. mock.restore();
  40. });
  41. function writePackageJson(dir: string): void {
  42. mkdirSync(dir, { recursive: true });
  43. writeFileSync(
  44. join(dir, 'package.json'),
  45. JSON.stringify({ name: 'oh-my-opencode-slim' }),
  46. );
  47. }
  48. test('stripJsonComments strips comments and trailing commas', () => {
  49. const jsonc = `{
  50. // comment
  51. "a": 1, /* multi
  52. line */
  53. "b": [2,],
  54. }`;
  55. const stripped = stripJsonComments(jsonc);
  56. expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] });
  57. });
  58. test('parseConfigFile parses valid JSON', () => {
  59. const path = join(tmpDir, 'test.json');
  60. writeFileSync(path, '{"a": 1}');
  61. const result = parseConfigFile(path);
  62. expect(result.config).toEqual({ a: 1 } as any);
  63. expect(result.error).toBeUndefined();
  64. });
  65. test('parseConfigFile returns null for non-existent file', () => {
  66. const result = parseConfigFile(join(tmpDir, 'nonexistent.json'));
  67. expect(result.config).toBeNull();
  68. });
  69. test('parseConfigFile returns null for empty or whitespace-only file', () => {
  70. const emptyPath = join(tmpDir, 'empty.json');
  71. writeFileSync(emptyPath, '');
  72. expect(parseConfigFile(emptyPath).config).toBeNull();
  73. const whitespacePath = join(tmpDir, 'whitespace.json');
  74. writeFileSync(whitespacePath, ' \n ');
  75. expect(parseConfigFile(whitespacePath).config).toBeNull();
  76. });
  77. test('parseConfigFile returns error for invalid JSON', () => {
  78. const path = join(tmpDir, 'invalid.json');
  79. writeFileSync(path, '{"a": 1');
  80. const result = parseConfigFile(path);
  81. expect(result.config).toBeNull();
  82. expect(result.error).toBeDefined();
  83. });
  84. test('parseConfig tries .jsonc if .json is missing', () => {
  85. const jsoncPath = join(tmpDir, 'test.jsonc');
  86. writeFileSync(jsoncPath, '{"a": 1}');
  87. // We pass .json path, it should try .jsonc
  88. const result = parseConfig(join(tmpDir, 'test.json'));
  89. expect(result.config).toEqual({ a: 1 } as any);
  90. });
  91. test('writeConfig writes JSON and creates backup', () => {
  92. const path = join(tmpDir, 'test.json');
  93. writeFileSync(path, '{"old": true}');
  94. writeConfig(path, { new: true } as any);
  95. expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual({ new: true });
  96. expect(JSON.parse(readFileSync(`${path}.bak`, 'utf-8'))).toEqual({
  97. old: true,
  98. });
  99. });
  100. test('addPluginToOpenCodeConfig adds plugin and removes duplicates', async () => {
  101. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  102. paths.ensureConfigDir();
  103. writeFileSync(
  104. configPath,
  105. JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
  106. );
  107. process.argv[1] = '';
  108. const result = await addPluginToOpenCodeConfig();
  109. expect(result.success).toBe(true);
  110. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  111. expect(saved.plugin).toContain('oh-my-opencode-slim');
  112. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  113. expect(saved.plugin.length).toBe(2);
  114. });
  115. test('addPluginToOpenCodeConfig stores package name for bunx temp paths', async () => {
  116. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  117. const packageRoot = join(
  118. tmpDir,
  119. 'bunx-1000-oh-my-opencode-slim@latest',
  120. 'node_modules',
  121. 'oh-my-opencode-slim',
  122. );
  123. paths.ensureConfigDir();
  124. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  125. writePackageJson(packageRoot);
  126. process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
  127. const result = await addPluginToOpenCodeConfig();
  128. expect(result.success).toBe(true);
  129. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  130. expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
  131. });
  132. test('addPluginToOpenCodeConfig stores local repo path for local dev paths', async () => {
  133. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  134. const packageRoot = join(tmpDir, 'repo');
  135. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  136. paths.ensureConfigDir();
  137. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  138. writePackageJson(packageRoot);
  139. process.argv[1] = localCliPath;
  140. const result = await addPluginToOpenCodeConfig();
  141. expect(result.success).toBe(true);
  142. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  143. expect(saved.plugin).toEqual([packageRoot]);
  144. });
  145. test('addPluginToOpenCodeConfig stores local repo path for local paths containing bunx-', async () => {
  146. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  147. const packageRoot = join(tmpDir, 'repo', 'bunx-tools');
  148. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  149. paths.ensureConfigDir();
  150. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  151. writePackageJson(packageRoot);
  152. process.argv[1] = localCliPath;
  153. const result = await addPluginToOpenCodeConfig();
  154. expect(result.success).toBe(true);
  155. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  156. expect(saved.plugin).toEqual([packageRoot]);
  157. });
  158. test('addPluginToOpenCodeConfig deduplicates existing local repo path entries', async () => {
  159. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  160. const packageRoot = join(tmpDir, 'repo');
  161. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  162. paths.ensureConfigDir();
  163. writePackageJson(packageRoot);
  164. writeFileSync(
  165. configPath,
  166. JSON.stringify({ plugin: ['other', packageRoot] }),
  167. );
  168. process.argv[1] = localCliPath;
  169. const result = await addPluginToOpenCodeConfig();
  170. expect(result.success).toBe(true);
  171. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  172. expect(saved.plugin).toEqual(['other', packageRoot]);
  173. });
  174. test('addPluginToOpenCodeConfig preserves non-string plugin entries when refreshing', async () => {
  175. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  176. paths.ensureConfigDir();
  177. process.argv[1] = '';
  178. const objectPlugin = { name: 'some-config-plugin', enabled: true };
  179. writeFileSync(
  180. configPath,
  181. JSON.stringify({
  182. plugin: ['other-plugin', objectPlugin, 'oh-my-opencode-slim@1.0.0'],
  183. }),
  184. );
  185. const result = await addPluginToOpenCodeConfig();
  186. expect(result.success).toBe(true);
  187. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  188. expect(saved.plugin).toContain('oh-my-opencode-slim');
  189. expect(saved.plugin).toContain('other-plugin');
  190. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  191. // Non-string entries (objects) must survive the plugin refresh
  192. expect(saved.plugin).toContainEqual(objectPlugin);
  193. expect(saved.plugin.length).toBe(3);
  194. });
  195. test('writeLiteConfig writes lite config with OpenAI preset', () => {
  196. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  197. paths.ensureConfigDir();
  198. const result = writeLiteConfig({
  199. hasTmux: true,
  200. installSkills: false,
  201. installCustomSkills: false,
  202. reset: false,
  203. });
  204. expect(result.success).toBe(true);
  205. const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
  206. expect(saved.$schema).toBe(
  207. 'https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json',
  208. );
  209. expect(saved.preset).toBe('openai');
  210. expect(saved.presets.openai).toBeDefined();
  211. expect(saved.tmux.enabled).toBe(true);
  212. });
  213. test('disableDefaultAgents disables explore and general agents', () => {
  214. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  215. paths.ensureConfigDir();
  216. writeFileSync(configPath, JSON.stringify({}));
  217. const result = disableDefaultAgents();
  218. expect(result.success).toBe(true);
  219. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  220. expect(saved.agent.explore.disable).toBe(true);
  221. expect(saved.agent.general.disable).toBe(true);
  222. });
  223. test('detectCurrentConfig detects installed status', () => {
  224. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  225. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  226. paths.ensureConfigDir();
  227. writeFileSync(
  228. configPath,
  229. JSON.stringify({
  230. plugin: ['oh-my-opencode-slim'],
  231. provider: {
  232. kimi: {
  233. npm: '@ai-sdk/openai-compatible',
  234. },
  235. },
  236. }),
  237. );
  238. writeFileSync(
  239. litePath,
  240. JSON.stringify({
  241. preset: 'openai',
  242. presets: {
  243. openai: {
  244. orchestrator: { model: 'openai/gpt-4' },
  245. oracle: { model: 'anthropic/claude-opus-4-6' },
  246. explorer: { model: 'github-copilot/grok-code-fast-1' },
  247. librarian: { model: 'zai-coding-plan/glm-4.7' },
  248. },
  249. },
  250. tmux: { enabled: true },
  251. }),
  252. );
  253. const detected = detectCurrentConfig();
  254. expect(detected.isInstalled).toBe(true);
  255. expect(detected.hasKimi).toBe(true);
  256. expect(detected.hasOpenAI).toBe(true);
  257. expect(detected.hasAnthropic).toBe(true);
  258. expect(detected.hasCopilot).toBe(true);
  259. expect(detected.hasZaiPlan).toBe(true);
  260. expect(detected.hasTmux).toBe(true);
  261. });
  262. test('detectCurrentConfig treats local repo path entries as installed', () => {
  263. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  264. const packageRoot = join(tmpDir, 'repo');
  265. paths.ensureConfigDir();
  266. writePackageJson(packageRoot);
  267. writeFileSync(configPath, JSON.stringify({ plugin: [packageRoot] }));
  268. const detected = detectCurrentConfig();
  269. expect(detected.isInstalled).toBe(true);
  270. });
  271. });