opencode-dux 1.0.0

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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,308 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { stripJsonComments } from '../../cli/config-manager';
5
+ import { log } from '../../utils/logger';
6
+ import {
7
+ INSTALLED_PACKAGE_JSON,
8
+ NPM_FETCH_TIMEOUT,
9
+ NPM_REGISTRY_URL,
10
+ PACKAGE_NAME,
11
+ USER_OPENCODE_CONFIG,
12
+ USER_OPENCODE_CONFIG_JSONC,
13
+ } from './constants';
14
+ import type {
15
+ NpmDistTags,
16
+ OpencodeConfig,
17
+ PackageJson,
18
+ PluginEntryInfo,
19
+ } from './types';
20
+
21
+ function isString(value: unknown): value is string {
22
+ return typeof value === 'string';
23
+ }
24
+
25
+ function getPluginEntries(config: OpencodeConfig): string[] {
26
+ return Array.isArray(config.plugin) ? config.plugin.filter(isString) : [];
27
+ }
28
+
29
+ /**
30
+ * Checks if a version string indicates a prerelease (contains a hyphen).
31
+ */
32
+ function isPrereleaseVersion(version: string): boolean {
33
+ return version.includes('-');
34
+ }
35
+
36
+ /**
37
+ * Checks if a version string is an NPM dist-tag (does not start with a digit).
38
+ */
39
+ function isDistTag(version: string): boolean {
40
+ return !/^\d/.test(version);
41
+ }
42
+
43
+ /**
44
+ * Extracts the update channel (latest, alpha, beta, etc.) from a version string.
45
+ * @param version The version or tag to analyze.
46
+ * @returns The channel name.
47
+ */
48
+ export function extractChannel(version: string | null): string {
49
+ if (!version) return 'latest';
50
+
51
+ if (isDistTag(version)) return version;
52
+
53
+ if (isPrereleaseVersion(version)) {
54
+ const prereleasePart = version.split('-')[1];
55
+ if (prereleasePart) {
56
+ const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/);
57
+ if (channelMatch) return channelMatch[1];
58
+ }
59
+ }
60
+
61
+ return 'latest';
62
+ }
63
+
64
+ /**
65
+ * Generates a list of potential OpenCode configuration file paths.
66
+ * @param directory The current plugin directory to check for local .opencode folders.
67
+ */
68
+ function getConfigPaths(directory: string): string[] {
69
+ return [
70
+ path.join(directory, '.opencode', 'opencode.json'),
71
+ path.join(directory, '.opencode', 'opencode.jsonc'),
72
+ USER_OPENCODE_CONFIG,
73
+ USER_OPENCODE_CONFIG_JSONC,
74
+ ];
75
+ }
76
+
77
+ /**
78
+ * Attempts to find a local development path (file://) for the plugin in configs.
79
+ */
80
+ function getLocalDevPath(directory: string): string | null {
81
+ for (const configPath of getConfigPaths(directory)) {
82
+ try {
83
+ if (!fs.existsSync(configPath)) continue;
84
+ const content = fs.readFileSync(configPath, 'utf-8');
85
+ const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
86
+ const plugins = getPluginEntries(config);
87
+
88
+ for (const entry of plugins) {
89
+ if (entry.startsWith('file://') && entry.includes(PACKAGE_NAME)) {
90
+ try {
91
+ return fileURLToPath(entry);
92
+ } catch {
93
+ return entry.replace('file://', '');
94
+ }
95
+ }
96
+ }
97
+ } catch {}
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Recursively searches upwards for a package.json belonging to this plugin.
104
+ */
105
+ function findPackageJsonUp(startPath: string): string | null {
106
+ try {
107
+ const stat = fs.statSync(startPath);
108
+ let dir = stat.isDirectory() ? startPath : path.dirname(startPath);
109
+
110
+ for (let i = 0; i < 10; i++) {
111
+ const pkgPath = path.join(dir, 'package.json');
112
+ if (fs.existsSync(pkgPath)) {
113
+ try {
114
+ const content = fs.readFileSync(pkgPath, 'utf-8');
115
+ const pkg = JSON.parse(content) as PackageJson;
116
+ if (pkg.name === PACKAGE_NAME) return pkgPath;
117
+ } catch {
118
+ /* empty */
119
+ }
120
+ }
121
+ const parent = path.dirname(dir);
122
+ if (parent === dir) break;
123
+ dir = parent;
124
+ }
125
+ } catch {
126
+ /* empty */
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Resolves the version of the plugin when running in local development mode.
133
+ */
134
+ export function getLocalDevVersion(directory: string): string | null {
135
+ const localPath = getLocalDevPath(directory);
136
+ if (!localPath) return null;
137
+
138
+ try {
139
+ const pkgPath = findPackageJsonUp(localPath);
140
+ if (!pkgPath) return null;
141
+ const content = fs.readFileSync(pkgPath, 'utf-8');
142
+ const pkg = JSON.parse(content) as PackageJson;
143
+ return pkg.version ?? null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Resolves the package.json for the currently running plugin bundle.
151
+ */
152
+ export function getCurrentRuntimePackageJsonPath(
153
+ currentModuleUrl: string = import.meta.url,
154
+ ): string | null {
155
+ try {
156
+ const currentDir = path.dirname(fileURLToPath(currentModuleUrl));
157
+ return findPackageJsonUp(currentDir);
158
+ } catch (err) {
159
+ log('[auto-update-checker] Failed to resolve runtime package path:', err);
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Searches across all config locations to find the current installation entry for this plugin.
166
+ */
167
+ export function findPluginEntry(directory: string): PluginEntryInfo | null {
168
+ for (const configPath of getConfigPaths(directory)) {
169
+ try {
170
+ if (!fs.existsSync(configPath)) continue;
171
+ const content = fs.readFileSync(configPath, 'utf-8');
172
+ const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
173
+ const plugins = getPluginEntries(config);
174
+
175
+ for (const entry of plugins) {
176
+ if (entry === PACKAGE_NAME) {
177
+ return { entry, isPinned: false, pinnedVersion: null, configPath };
178
+ }
179
+ if (entry.startsWith(`${PACKAGE_NAME}@`)) {
180
+ const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
181
+ const isPinned = pinnedVersion !== 'latest';
182
+ return {
183
+ entry,
184
+ isPinned,
185
+ pinnedVersion: isPinned ? pinnedVersion : null,
186
+ configPath,
187
+ };
188
+ }
189
+ }
190
+ } catch {}
191
+ }
192
+ return null;
193
+ }
194
+
195
+ const _cachedLocalVersion: string | null = null;
196
+ let cachedPackageVersion: string | null = null;
197
+
198
+ /**
199
+ * Resolves the installed version from node_modules, with memoization.
200
+ */
201
+ export function getCachedVersion(): string | null {
202
+ if (cachedPackageVersion) return cachedPackageVersion;
203
+
204
+ try {
205
+ const runtimePackageJsonPath = getCurrentRuntimePackageJsonPath();
206
+ if (runtimePackageJsonPath && fs.existsSync(runtimePackageJsonPath)) {
207
+ const content = fs.readFileSync(runtimePackageJsonPath, 'utf-8');
208
+ const pkg = JSON.parse(content) as PackageJson;
209
+ if (pkg.version) {
210
+ cachedPackageVersion = pkg.version;
211
+ return pkg.version;
212
+ }
213
+ }
214
+ } catch {
215
+ /* empty */
216
+ }
217
+
218
+ try {
219
+ if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
220
+ const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, 'utf-8');
221
+ const pkg = JSON.parse(content) as PackageJson;
222
+ if (pkg.version) {
223
+ cachedPackageVersion = pkg.version;
224
+ return pkg.version;
225
+ }
226
+ }
227
+ } catch (err) {
228
+ log(
229
+ '[auto-update-checker] Failed to resolve version from current directory:',
230
+ err,
231
+ );
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Safely updates a pinned version in the configuration file.
239
+ * It attempts to replace the exact plugin string to preserve comments and formatting.
240
+ */
241
+ export function updatePinnedVersion(
242
+ configPath: string,
243
+ oldEntry: string,
244
+ newVersion: string,
245
+ ): boolean {
246
+ try {
247
+ if (!fs.existsSync(configPath)) return false;
248
+
249
+ const content = fs.readFileSync(configPath, 'utf-8');
250
+ const newEntry = `${PACKAGE_NAME}@${newVersion}`;
251
+
252
+ // Check if the old entry actually exists as a quoted string
253
+ const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
254
+ const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g');
255
+
256
+ if (!entryRegex.test(content)) {
257
+ log(
258
+ `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`,
259
+ );
260
+ return false;
261
+ }
262
+
263
+ // Perform the replacement
264
+ const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`);
265
+
266
+ if (updatedContent === content) {
267
+ return false;
268
+ }
269
+
270
+ fs.writeFileSync(configPath, updatedContent, 'utf-8');
271
+ log(
272
+ `[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`,
273
+ );
274
+ return true;
275
+ } catch (err) {
276
+ log(
277
+ `[auto-update-checker] Failed to update config file ${configPath}:`,
278
+ err,
279
+ );
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Fetches the latest version for a specific channel from the NPM registry.
286
+ */
287
+ export async function getLatestVersion(
288
+ channel: string = 'latest',
289
+ ): Promise<string | null> {
290
+ const controller = new AbortController();
291
+ const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
292
+
293
+ try {
294
+ const response = await fetch(NPM_REGISTRY_URL, {
295
+ signal: controller.signal,
296
+ headers: { Accept: 'application/json' },
297
+ });
298
+
299
+ if (!response.ok) return null;
300
+
301
+ const data = (await response.json()) as NpmDistTags;
302
+ return data[channel] ?? data.latest ?? null;
303
+ } catch {
304
+ return null;
305
+ } finally {
306
+ clearTimeout(timeoutId);
307
+ }
308
+ }
@@ -0,0 +1,33 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { getOpenCodeConfigPaths } from '../../cli/config-manager';
4
+
5
+ export const PACKAGE_NAME = 'opencode-dux';
6
+ export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
7
+ export const NPM_FETCH_TIMEOUT = 5000;
8
+
9
+ function getCacheDir(): string {
10
+ if (process.platform === 'win32') {
11
+ return path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'opencode');
12
+ }
13
+ return path.join(os.homedir(), '.cache', 'opencode');
14
+ }
15
+
16
+ /** The directory used by OpenCode to cache node_modules for plugins. */
17
+ export const CACHE_DIR = getCacheDir();
18
+
19
+ /** Path to this plugin's package.json within the OpenCode cache. */
20
+ export const INSTALLED_PACKAGE_JSON = path.join(
21
+ CACHE_DIR,
22
+ 'node_modules',
23
+ PACKAGE_NAME,
24
+ 'package.json',
25
+ );
26
+
27
+ const configPaths = getOpenCodeConfigPaths();
28
+
29
+ /** Primary OpenCode configuration file path (standard JSON). */
30
+ export const USER_OPENCODE_CONFIG = configPaths[0];
31
+
32
+ /** Alternative OpenCode configuration file path (JSON with Comments). */
33
+ export const USER_OPENCODE_CONFIG_JSONC = configPaths[1];
@@ -0,0 +1,282 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ const logMock = mock(() => {});
4
+
5
+ const checkerMocks = {
6
+ extractChannel: mock(() => 'latest'),
7
+ findPluginEntry: mock(() => null),
8
+ getCachedVersion: mock(() => null),
9
+ getLatestVersion: mock(async () => null),
10
+ getLocalDevVersion: mock(() => null),
11
+ getCurrentRuntimePackageJsonPath: mock(() => null),
12
+ };
13
+
14
+ const cacheMocks = {
15
+ preparePackageUpdate: mock(() => '/tmp/opencode'),
16
+ resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
17
+ };
18
+
19
+ const crossSpawnMock = mock((_command: string[]) => ({
20
+ exited: Promise.resolve(0),
21
+ exitCode: 0,
22
+ kill: mock(() => true),
23
+ stdout: () => Promise.resolve(''),
24
+ stderr: () => Promise.resolve(''),
25
+ proc: {} as never,
26
+ }));
27
+
28
+ mock.module('../../utils/logger', () => ({
29
+ log: logMock,
30
+ }));
31
+
32
+ mock.module('./checker', () => checkerMocks);
33
+
34
+ mock.module('./cache', () => cacheMocks);
35
+
36
+ mock.module('../../utils/compat', () => ({
37
+ crossSpawn: crossSpawnMock,
38
+ crossWrite: mock(() => Promise.resolve()),
39
+ isBun: false,
40
+ }));
41
+
42
+ let importCounter = 0;
43
+
44
+ function createCtx() {
45
+ const showToast = mock(() => Promise.resolve(undefined));
46
+
47
+ return {
48
+ ctx: {
49
+ directory: '/test',
50
+ client: {
51
+ tui: {
52
+ showToast,
53
+ },
54
+ },
55
+ },
56
+ showToast,
57
+ };
58
+ }
59
+
60
+ async function waitForCalls(
61
+ fn: { mock: { calls: unknown[] } },
62
+ minCalls = 1,
63
+ ): Promise<void> {
64
+ const deadline = Date.now() + 1000;
65
+
66
+ while (fn.mock.calls.length < minCalls) {
67
+ if (Date.now() > deadline) {
68
+ throw new Error('Timed out waiting for async hook work');
69
+ }
70
+
71
+ await new Promise((resolve) => setTimeout(resolve, 0));
72
+ }
73
+ }
74
+
75
+ describe('auto-update-checker/index', () => {
76
+ beforeEach(() => {
77
+ logMock.mockClear();
78
+
79
+ checkerMocks.extractChannel.mockReset();
80
+ checkerMocks.extractChannel.mockImplementation(() => 'latest');
81
+ checkerMocks.findPluginEntry.mockReset();
82
+ checkerMocks.findPluginEntry.mockImplementation(() => null);
83
+ checkerMocks.getCachedVersion.mockReset();
84
+ checkerMocks.getCachedVersion.mockImplementation(() => null);
85
+ checkerMocks.getLatestVersion.mockReset();
86
+ checkerMocks.getLatestVersion.mockImplementation(async () => null);
87
+ checkerMocks.getLocalDevVersion.mockReset();
88
+ checkerMocks.getLocalDevVersion.mockImplementation(() => null);
89
+
90
+ cacheMocks.preparePackageUpdate.mockReset();
91
+ cacheMocks.preparePackageUpdate.mockImplementation(() => '/tmp/opencode');
92
+ cacheMocks.resolveInstallContext.mockReset();
93
+ cacheMocks.resolveInstallContext.mockImplementation(() => ({
94
+ installDir: '/tmp/opencode',
95
+ }));
96
+
97
+ crossSpawnMock.mockReset();
98
+ crossSpawnMock.mockImplementation(() => ({
99
+ exited: Promise.resolve(0),
100
+ exitCode: 0,
101
+ kill: mock(() => true),
102
+ stdout: () => Promise.resolve(''),
103
+ stderr: () => Promise.resolve(''),
104
+ proc: {} as never,
105
+ }));
106
+ });
107
+
108
+ afterEach(() => {
109
+ // Mocks are automatically cleared by Bun's test runner between tests
110
+ });
111
+
112
+ test('uses resolved install root for auto-update installs', async () => {
113
+ const { getAutoUpdateInstallDir } = await import(
114
+ `./index?test=${importCounter++}`
115
+ );
116
+
117
+ expect(getAutoUpdateInstallDir()).toBe('/tmp/opencode');
118
+ });
119
+
120
+ test('skips background update for local dev installs without startup toast', async () => {
121
+ checkerMocks.getLocalDevVersion.mockImplementation(() => '0.9.11-dev');
122
+
123
+ const { createAutoUpdateCheckerHook } = await import(
124
+ `./index?test=${importCounter++}`
125
+ );
126
+ const { ctx, showToast } = createCtx();
127
+
128
+ const hook = createAutoUpdateCheckerHook(ctx as never);
129
+ hook.event({ event: { type: 'session.created', properties: {} } });
130
+ await waitForCalls(logMock);
131
+
132
+ expect(showToast).not.toHaveBeenCalled();
133
+ expect(checkerMocks.findPluginEntry).not.toHaveBeenCalled();
134
+ expect(checkerMocks.getLatestVersion).not.toHaveBeenCalled();
135
+ });
136
+
137
+ test('shows success toast after updating the active install root', async () => {
138
+ checkerMocks.findPluginEntry.mockImplementation(() => ({
139
+ pinnedVersion: null,
140
+ isPinned: false,
141
+ }));
142
+ checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
143
+ checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
144
+
145
+ crossSpawnMock.mockImplementation(() => ({
146
+ exited: Promise.resolve(0),
147
+ exitCode: 0,
148
+ kill: mock(() => true),
149
+ stdout: () => Promise.resolve(''),
150
+ stderr: () => Promise.resolve(''),
151
+ proc: {} as never,
152
+ }));
153
+
154
+ const { createAutoUpdateCheckerHook } = await import(
155
+ `./index?test=${importCounter++}`
156
+ );
157
+ const { ctx, showToast } = createCtx();
158
+
159
+ const hook = createAutoUpdateCheckerHook(ctx as never);
160
+ hook.event({ event: { type: 'session.created', properties: {} } });
161
+ await waitForCalls(showToast);
162
+
163
+ expect(cacheMocks.preparePackageUpdate).toHaveBeenCalledWith(
164
+ '0.9.11',
165
+ 'opencode-dux',
166
+ );
167
+ expect(crossSpawnMock).toHaveBeenCalledWith(
168
+ ['bun', 'install'],
169
+ expect.objectContaining({ cwd: '/tmp/opencode' }),
170
+ );
171
+ expect(showToast).toHaveBeenCalledWith({
172
+ body: {
173
+ title: 'OMO-Slim Updated!',
174
+ message: 'v0.9.1 → v0.9.11\nRestart OpenCode to apply.',
175
+ variant: 'success',
176
+ duration: 8000,
177
+ },
178
+ });
179
+ });
180
+
181
+ test('shows notification-only toast when auto-update is disabled', async () => {
182
+ checkerMocks.findPluginEntry.mockImplementation(() => ({
183
+ pinnedVersion: null,
184
+ isPinned: false,
185
+ }));
186
+ checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
187
+ checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
188
+
189
+ const { createAutoUpdateCheckerHook } = await import(
190
+ `./index?test=${importCounter++}`
191
+ );
192
+ const { ctx, showToast } = createCtx();
193
+
194
+ const hook = createAutoUpdateCheckerHook(ctx as never, {
195
+ autoUpdate: false,
196
+ });
197
+ hook.event({ event: { type: 'session.created', properties: {} } });
198
+ await waitForCalls(showToast);
199
+
200
+ expect(showToast).toHaveBeenCalledWith({
201
+ body: {
202
+ title: 'OMO-Slim 0.9.11',
203
+ message: 'v0.9.11 available. Auto-update is disabled.',
204
+ variant: 'info',
205
+ duration: 8000,
206
+ },
207
+ });
208
+ expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
209
+ expect(crossSpawnMock).not.toHaveBeenCalled();
210
+ });
211
+
212
+ test('shows prepare failure toast and skips installation when active install cannot be resolved', async () => {
213
+ checkerMocks.findPluginEntry.mockImplementation(() => ({
214
+ pinnedVersion: null,
215
+ isPinned: false,
216
+ }));
217
+ checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
218
+ checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
219
+ cacheMocks.preparePackageUpdate.mockImplementation(() => null);
220
+
221
+ const { createAutoUpdateCheckerHook } = await import(
222
+ `./index?test=${importCounter++}`
223
+ );
224
+ const { ctx, showToast } = createCtx();
225
+
226
+ const hook = createAutoUpdateCheckerHook(ctx as never);
227
+ hook.event({ event: { type: 'session.created', properties: {} } });
228
+ await waitForCalls(showToast);
229
+
230
+ expect(crossSpawnMock).not.toHaveBeenCalled();
231
+ expect(showToast).toHaveBeenCalledWith({
232
+ body: {
233
+ title: 'OMO-Slim 0.9.11',
234
+ message:
235
+ 'v0.9.11 available. Auto-update could not prepare the active install.',
236
+ variant: 'info',
237
+ duration: 8000,
238
+ },
239
+ });
240
+ });
241
+
242
+ test('shows install failure toast without telling users to restart', async () => {
243
+ checkerMocks.findPluginEntry.mockImplementation(() => ({
244
+ pinnedVersion: null,
245
+ isPinned: false,
246
+ }));
247
+ checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
248
+ checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
249
+
250
+ crossSpawnMock.mockImplementation(() => ({
251
+ exited: Promise.resolve(1),
252
+ exitCode: 1,
253
+ kill: mock(() => true),
254
+ stdout: () => Promise.resolve(''),
255
+ stderr: () => Promise.resolve(''),
256
+ proc: {} as never,
257
+ }));
258
+
259
+ const { createAutoUpdateCheckerHook } = await import(
260
+ `./index?test=${importCounter++}`
261
+ );
262
+ const { ctx, showToast } = createCtx();
263
+
264
+ const hook = createAutoUpdateCheckerHook(ctx as never);
265
+ hook.event({ event: { type: 'session.created', properties: {} } });
266
+ await waitForCalls(showToast);
267
+
268
+ expect(crossSpawnMock).toHaveBeenCalledWith(
269
+ ['bun', 'install'],
270
+ expect.objectContaining({ cwd: '/tmp/opencode' }),
271
+ );
272
+ expect(showToast).toHaveBeenCalledWith({
273
+ body: {
274
+ title: 'OMO-Slim 0.9.11',
275
+ message:
276
+ 'v0.9.11 available, but auto-update failed to install it. Check logs or retry manually.',
277
+ variant: 'error',
278
+ duration: 8000,
279
+ },
280
+ });
281
+ });
282
+ });