opencode-rules-md 0.8.5 → 0.8.6

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.
package/src/cli/update.ts CHANGED
@@ -1,85 +1,183 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // src/cli/update.ts — `omd update` command implementation.
3
3
  //
4
- // Fetches latest from npm, compares to installed version, purges the
5
- // ~/.cache/opencode/node_modules/opencode-rules-md cache, and prints the
6
- // reinstall instruction when stale. No auto-reinstall.
4
+ // Like install, update is now a thin wrapper around OpenCode's own CLI.
5
+ // We compare the installed version (read from `data['plugin']` — singular —
6
+ // with a backward-compat fallback to the legacy `data['plugins']`) against
7
+ // the latest npm version. When stale we purge the on-disk cache under
8
+ // ~/.cache/opencode/packages/ (the actual location OpenCode uses) and
9
+ // invoke `opencode plugin opencode-rules-md --global --force` to refresh
10
+ // the registration.
11
+ //
12
+ // The cache purge matters because OpenCode caches the resolved package
13
+ // by specifier — once a cached copy exists, `--force` re-registers without
14
+ // re-fetching unless we first remove the stale entry.
7
15
  // ---------------------------------------------------------------------------
8
16
 
9
17
  import { homedir } from 'os';
10
18
  import { join } from 'path';
11
19
  import { fetchLatestVersion, isStale } from './registry.js';
12
- import { loadGlobalConfig, matchesPlugin, normalizePlugin } from './config.js';
13
- import type { CliFs } from './config.js';
20
+ import {
21
+ loadGlobalConfig,
22
+ matchesPlugin,
23
+ readInstalledPlugins,
24
+ type CliFs,
25
+ type LoadedConfig,
26
+ } from './config.js';
27
+ import { spawnOpencodePlugin } from './spawn.js';
28
+
29
+ const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
30
+
31
+ /** Package directory used by OpenCode to cache plugin installs. */
32
+ export const PACKAGES_DIR_BASENAME = ['.cache', 'opencode', 'packages'] as const;
33
+
34
+ /** Exact cache directory name we look for (bare specifier, no version suffix). */
35
+ export const CACHE_DIR_BASENAME = 'opencode-rules-md';
14
36
 
15
- // ─── Constants ───────────────────────────────────────────────────────────────
37
+ /**
38
+ * Resolve the user's home directory, honoring a custom HOME env var.
39
+ * Used by both resolveCachePaths and the config loader.
40
+ */
41
+ export function resolveHome(env: NodeJS.ProcessEnv = process.env): string {
42
+ return env.HOME ?? homedir();
43
+ }
16
44
 
17
- export const CACHE_PATH = join(
18
- homedir(),
19
- '.cache',
20
- 'opencode',
21
- 'node_modules',
22
- 'opencode-rules-md',
23
- );
45
+ /**
46
+ * Return the absolute path of the OpenCode packages cache directory.
47
+ */
48
+ export function resolvePackagesDir(env: NodeJS.ProcessEnv = process.env): string {
49
+ return join(resolveHome(env), ...PACKAGES_DIR_BASENAME);
50
+ }
24
51
 
25
- // ─── Types ───────────────────────────────────────────────────────────────────
52
+ /**
53
+ * Return the cache directories that match the opencode-rules-md prefix.
54
+ *
55
+ * Real-world layout under ~/.cache/opencode/packages/ looks like:
56
+ * opencode-rules-md/
57
+ * opencode-rules-md@latest/
58
+ * some-other-plugin/
59
+ *
60
+ * We glob-match anything starting with `opencode-rules-md` (exact or with
61
+ * an `@<version>` suffix). When an injected fs is provided we list the
62
+ * directory and filter; without one we return the two most common shapes
63
+ * so callers can pre-check existence cheaply.
64
+ */
65
+ export function resolveCachePaths(
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ fs?: CliFs,
68
+ ): string[] {
69
+ const packagesDir = resolvePackagesDir(env);
70
+ if (fs && fs.existsSync(packagesDir)) {
71
+ try {
72
+ const entries = fs.readdirSync(packagesDir);
73
+ return entries
74
+ .filter(
75
+ (name) => name === CACHE_DIR_BASENAME || name.startsWith(`${CACHE_DIR_BASENAME}@`),
76
+ )
77
+ .map((name) => join(packagesDir, name));
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+ // No fs injection — return the conventional candidates. Callers that care
83
+ // about existence should still check with existsSync().
84
+ return [
85
+ join(packagesDir, CACHE_DIR_BASENAME),
86
+ join(packagesDir, `${CACHE_DIR_BASENAME}@latest`),
87
+ ];
88
+ }
26
89
 
27
90
  export interface UpdateOptions {
91
+ /** Injected latest version (test seam). Defaults to a real npm lookup. */
28
92
  latestVersion?: string | null | undefined;
93
+ /** Print the plan without writing or spawning. */
29
94
  dryRun?: boolean;
95
+ /** Injected spawn function (test seam). Defaults to spawnOpencodePlugin. */
96
+ spawn?: typeof spawnOpencodePlugin;
30
97
  }
31
98
 
32
99
  export interface UpdateResult {
33
100
  status: 'stale' | 'current' | 'unreachable';
34
- cachePath: string;
101
+ cachePaths: string[];
102
+ /** Re-install instruction; empty when status === 'current'. */
35
103
  instruction: string;
36
104
  }
37
105
 
38
- // ─── Cache path helpers ───────────────────────────────────────────────────────
106
+ /**
107
+ * Look up the installed specifier across both config files, returning the
108
+ * first one we find. Returns null when neither file registers the plugin.
109
+ */
110
+ function findInstalledSpecifier(loaded: readonly LoadedConfig[]): string | null {
111
+ for (const cfg of loaded) {
112
+ const plugins = readInstalledPlugins(cfg);
113
+ const match = plugins.find((p) => matchesPlugin(p));
114
+ if (match) return match;
115
+ }
116
+ return null;
117
+ }
39
118
 
40
119
  /**
41
- * Resolve the local cache directory, honoring a custom HOME environment.
42
- * Falls back to os.homedir() when HOME is not set, matching the
43
- * resolveConfigDir fallback strategy.
120
+ * Extract the version portion of an `opencode-rules-md@<version>` specifier.
121
+ * Returns null when the specifier has no `@version` segment.
44
122
  */
45
- export function resolveCachePath(env: NodeJS.ProcessEnv = process.env): string {
46
- return join(
47
- env.HOME ?? homedir(),
48
- '.cache',
49
- 'opencode',
50
- 'node_modules',
51
- 'opencode-rules-md',
52
- );
123
+ export function extractVersion(specifier: string): string | null {
124
+ const at = specifier.lastIndexOf('@');
125
+ if (at < 0 || at === specifier.length - 1) return null;
126
+ return specifier.slice(at + 1);
53
127
  }
54
128
 
55
129
  /**
56
- * Read the cached package.json version, if determinable.
57
- * Returns null when the cache is missing or unreadable.
130
+ * Recursively delete a directory and all its contents using the injected fs.
131
+ * Best-effort a failed purge is not fatal; we want the update to keep going.
58
132
  */
59
- function readCacheVersion(fs: CliFs, cachePath: string): string | null {
60
- const pkgPath = join(cachePath, 'package.json');
133
+ export function purgeDirectory(fs: CliFs, dirPath: string): void {
134
+ let entries: string[] = [];
61
135
  try {
62
- if (!fs.existsSync(pkgPath)) {
63
- return null;
64
- }
65
- const pkg = JSON.parse(fs.readFileSync(pkgPath)) as { version?: unknown };
66
- return typeof pkg.version === 'string' ? pkg.version : null;
136
+ entries = fs.readdirSync(dirPath);
67
137
  } catch {
68
- return null;
138
+ return;
69
139
  }
70
- }
71
140
 
72
- // ─── runUpdate ────────────────────────────────────────────────────────────────
141
+ for (const entry of entries) {
142
+ const entryPath = join(dirPath, entry);
143
+ try {
144
+ if (!fs.existsSync(entryPath)) continue;
145
+ // Probe to differentiate empty dirs / non-empty dirs / files. The injected
146
+ // fs interface does not expose stat, so we try readdir first and fall back
147
+ // to unlink for leaves.
148
+ try {
149
+ const subEntries = fs.readdirSync(entryPath);
150
+ if (subEntries.length === 0) {
151
+ fs.rmdirSync(entryPath);
152
+ } else {
153
+ purgeDirectory(fs, entryPath);
154
+ fs.rmdirSync(entryPath);
155
+ }
156
+ } catch {
157
+ // Not a directory (or unreadable) — best-effort unlink.
158
+ fs.unlinkSync(entryPath);
159
+ }
160
+ } catch {
161
+ // best-effort per entry
162
+ }
163
+ }
73
164
 
74
- const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
165
+ try {
166
+ fs.rmdirSync(dirPath);
167
+ } catch {
168
+ // best-effort
169
+ }
170
+ }
75
171
 
76
172
  /**
77
- * Run the update command:
78
- * 1. Fetch latest version from npm (or use provided mock)
79
- * 2. Compare to installed version in config
80
- * 3. If stale: purge cache, print reinstall instruction
81
- * 4. If current: print "already current"
82
- * 5. If unreachable: noop
173
+ * Run the update command.
174
+ *
175
+ * 1. Resolve the latest published version from npm (or use the injected mock).
176
+ * 2. Read the installed version from the user's opencode config.
177
+ * 3. If unreachable, log and return.
178
+ * 4. If current, log and return.
179
+ * 5. Otherwise: purge stale cache directories, then spawn
180
+ * `opencode plugin opencode-rules-md --global --force`.
83
181
  */
84
182
  export const runUpdate = async (
85
183
  fs: CliFs,
@@ -88,133 +186,69 @@ export const runUpdate = async (
88
186
  _error: (s: string) => void,
89
187
  opts: UpdateOptions = {},
90
188
  ): Promise<UpdateResult> => {
91
- // Use provided latestVersion for testing, otherwise fetch from npm
92
- const latest = opts.latestVersion !== undefined
93
- ? opts.latestVersion
94
- : await fetchLatestVersion();
189
+ // 1. Resolve latest.
190
+ const latest = opts.latestVersion !== undefined ? opts.latestVersion : await fetchLatestVersion();
95
191
 
96
- const cachePath = resolveCachePath(env);
192
+ const cachePaths = resolveCachePaths(env, fs);
193
+ const instruction = 'opencode plugin opencode-rules-md --global --force';
194
+ const spawnFn = opts.spawn ?? spawnOpencodePlugin;
97
195
 
196
+ // 2. Unreachable.
98
197
  if (latest === null) {
99
198
  log('omd: could not determine latest version from npm registry');
100
- return {
101
- status: 'unreachable',
102
- cachePath,
103
- instruction: 'npx opencode-rules-md@latest install',
104
- };
199
+ return { status: 'unreachable', cachePaths, instruction };
105
200
  }
106
201
 
107
- // Determine installed version from config (check both configs)
108
- let installedVersion: string | null = null;
109
- for (const basename of CONFIG_BASENAMES) {
110
- const loaded = loadGlobalConfig(fs, env, basename);
111
- const plugins = normalizePlugin(loaded.data['plugins']);
112
- const match = plugins.find(p => matchesPlugin(p));
113
- if (match) {
114
- // Extract version from specifier (e.g. 'opencode-rules-md@2.0.0' -> '2.0.0')
115
- const atIndex = match.lastIndexOf('@');
116
- installedVersion = atIndex >= 0 ? match.slice(atIndex + 1) : null;
117
- break; // use first config that has it
118
- }
119
- }
202
+ // 3. Installed version (read both configs).
203
+ const configs = CONFIG_BASENAMES.map((basename) => loadGlobalConfig(fs, env, basename));
204
+ const installedSpecifier = findInstalledSpecifier(configs);
205
+ const installedVersion = installedSpecifier ? extractVersion(installedSpecifier) : null;
120
206
 
121
- const instruction = `npx opencode-rules-md@latest install`;
122
-
123
- // Check staleness: only stale if installed version differs from latest
207
+ // 4. Current.
124
208
  if (installedVersion !== null && !isStale(installedVersion, latest)) {
125
209
  log(`omd: opencode-rules-md@${installedVersion} is already the latest`);
126
- return {
127
- status: 'current',
128
- cachePath,
129
- instruction,
130
- };
210
+ return { status: 'current', cachePaths, instruction: '' };
131
211
  }
132
212
 
133
- // Before purging, decide whether the cache is actually stale. When the cache
134
- // version is already equal to the latest resolved version, skip the purge
135
- // so we do not delete a valid cache. If the version cannot be determined,
136
- // fall back to purging to be safe.
137
- const cacheVersion = readCacheVersion(fs, cachePath);
138
- const shouldPurge = cacheVersion === null || cacheVersion !== latest;
139
-
213
+ // 5. Stale describe plan.
140
214
  if (opts.dryRun) {
141
- log(`omd: update check (dry-run)`);
215
+ log('omd: update check (dry-run)');
142
216
  log(` latest version: ${latest}`);
143
217
  log(` installed version: ${installedVersion ?? 'not installed'}`);
144
- log(` would purge: ${shouldPurge ? cachePath : '(nothing to purge)'}`);
218
+ log(` would purge: ${cachePaths.join(', ') || '(none found)'}`);
145
219
  log(` would instruct: ${instruction}`);
146
- return {
147
- status: 'stale',
148
- cachePath,
149
- instruction,
150
- };
220
+ return { status: 'stale', cachePaths, instruction };
151
221
  }
152
222
 
153
- // Purge the cache directory using the injected fs (allows test faking)
154
- if (shouldPurge) {
223
+ log(
224
+ installedVersion === null
225
+ ? `omd: opencode-rules-md is not installed; registering latest (${latest})`
226
+ : `omd: opencode-rules-md is stale (installed ${installedVersion}, latest ${latest})`,
227
+ );
228
+
229
+ // Purge each matching cache directory. Best-effort per path.
230
+ for (const cachePath of cachePaths) {
155
231
  try {
156
232
  if (fs.existsSync(cachePath)) {
157
- // Recursively remove contents then the dir itself
158
233
  purgeDirectory(fs, cachePath);
234
+ log(`omd: purged cache ${cachePath}`);
159
235
  }
160
236
  } catch {
161
- // best-effort purge failure is non-fatal
237
+ // ignore individual purge failures
162
238
  }
163
239
  }
164
240
 
165
- log(`omd: opencode-rules-md is stale (latest: ${latest})`);
166
- log(`omd: cache purged at ${cachePath}`);
167
- log(`omd: to reinstall, run:`);
168
- log(` ${instruction}`);
169
-
170
- return {
171
- status: 'stale',
172
- cachePath,
173
- instruction,
174
- };
175
- };
176
-
177
- // ─── purgeDirectory ────────────────────────────────────────────────────────────
178
-
179
- /**
180
- * Recursively delete a directory and all its contents using the given fs.
181
- */
182
- export function purgeDirectory(fs: CliFs, dirPath: string): void {
183
- let entries: string[] = [];
184
- try {
185
- entries = fs.readdirSync(dirPath);
186
- } catch {
187
- return;
188
- }
241
+ // Re-register via OpenCode's CLI. Failure here is fatal surface it.
242
+ const result = await spawnFn(['opencode-rules-md', '--global', '--force'], {
243
+ env: process.env,
244
+ stdio: 'inherit',
245
+ });
189
246
 
190
- for (const entry of entries) {
191
- const entryPath = join(dirPath, entry);
192
- try {
193
- if (fs.existsSync(entryPath)) {
194
- // Check if it's a directory by trying to read it
195
- try {
196
- const subEntries = fs.readdirSync(entryPath);
197
- if (subEntries.length === 0) {
198
- // Empty directory
199
- fs.rmdirSync(entryPath);
200
- } else {
201
- // Non-empty directory — recurse
202
- purgeDirectory(fs, entryPath);
203
- fs.rmdirSync(entryPath);
204
- }
205
- } catch {
206
- // It's a file — unlink it
207
- fs.unlinkSync(entryPath);
208
- }
209
- }
210
- } catch {
211
- // best-effort per entry
212
- }
247
+ if ((result.status ?? 0) !== 0) {
248
+ throw new Error(
249
+ `opencode plugin opencode-rules-md --global --force exited with status ${String(result.status)}`,
250
+ );
213
251
  }
214
252
 
215
- try {
216
- fs.rmdirSync(dirPath);
217
- } catch {
218
- // best-effort
219
- }
220
- }
253
+ return { status: 'stale', cachePaths, instruction: '' };
254
+ };