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/status.ts CHANGED
@@ -1,24 +1,37 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // src/cli/status.ts — `omd status` and `omd doctor` command implementations.
3
3
  //
4
- // runStatus: read-only probe for both configs. Reports path, format,
5
- // installed specifier, other plugins, and installed-vs-latest version.
6
- // runDoctor: health checks grouped into issues (exit 1), warnings, and info.
4
+ // Three correctness fixes vs. the previous implementation:
5
+ //
6
+ // 1. We read `data['plugin']` (singular) as the source of truth, with a
7
+ // backward-compat fallback to `data['plugins']`. The legacy plural
8
+ // field is no longer authoritative.
9
+ // 2. `runDoctor` warns when a legacy `plugins` field is present, so users
10
+ // upgrading from the buggy version are nudged to clean up.
11
+ // 3. The cache freshness check uses ~/.cache/opencode/packages/ (where
12
+ // OpenCode actually stores plugin installs) instead of the legacy
13
+ // ~/.cache/opencode/node_modules/ path.
7
14
  // ---------------------------------------------------------------------------
8
15
 
9
- import { join, dirname } from 'path';
16
+ import { join, dirname, extname } from 'path';
10
17
  import { homedir } from 'os';
11
- import { extname } from 'path';
12
18
  import {
13
19
  loadGlobalConfig,
14
20
  matchesPlugin,
15
- normalizePlugin,
21
+ readInstalledPlugins,
16
22
  type CliFs,
23
+ type LoadedConfig,
17
24
  } from './config.js';
18
25
  import { fetchLatestVersion } from './registry.js';
26
+ import { resolveCachePaths } from './update.js';
19
27
 
20
28
  // ─── Status types ─────────────────────────────────────────────────────────────
21
29
 
30
+ export interface StatusOptions {
31
+ /** Override latest version for testing — skips the npm registry call */
32
+ latestVersion?: string | null;
33
+ }
34
+
22
35
  export interface StatusEntry {
23
36
  basename: string;
24
37
  path: string;
@@ -66,30 +79,35 @@ export const runStatus = async (
66
79
  fs: CliFs,
67
80
  env: NodeJS.ProcessEnv,
68
81
  log: (s: string) => void,
82
+ opts: StatusOptions = {},
69
83
  ): Promise<StatusResult> => {
70
84
  const configs: StatusEntry[] = [];
71
- const latest = await fetchLatestVersion();
85
+ const latest = opts.latestVersion !== undefined
86
+ ? opts.latestVersion
87
+ : await fetchLatestVersion();
72
88
 
73
89
  for (const basename of CONFIG_BASENAMES) {
74
90
  const loaded = loadGlobalConfig(fs, env, basename);
75
91
  const format = extname(loaded.path) as string; // '.json' or '.jsonc'
76
- const plugins = normalizePlugin(loaded.data['plugins']);
77
- const match = plugins.find(p => matchesPlugin(p));
92
+ const plugins = readInstalledPlugins(loaded);
93
+ const match = plugins.find((p) => matchesPlugin(p)) ?? null;
78
94
 
79
95
  const entry: StatusEntry = {
80
96
  basename,
81
97
  path: loaded.path,
82
98
  format,
83
- installed: match ?? null,
84
- notInstalled: !loaded.exists ? true : match === undefined || match === null,
85
- otherPlugins: plugins.filter(p => !matchesPlugin(p)),
99
+ installed: match,
100
+ notInstalled: !loaded.exists || match === null,
101
+ otherPlugins: plugins.filter((p) => !matchesPlugin(p)),
86
102
  latest,
87
- isLatest: match !== undefined && latest !== null ? match === `opencode-rules-md@${latest}` : null,
103
+ isLatest:
104
+ match !== null && latest !== null
105
+ ? match === `opencode-rules-md@${latest}`
106
+ : null,
88
107
  };
89
108
 
90
109
  configs.push(entry);
91
110
 
92
- // ── Print status lines ──────────────────────────────────────────────────
93
111
  if (!loaded.exists) {
94
112
  log(`omd: ${basename}.json — config not found at ${loaded.path}`);
95
113
  continue;
@@ -97,14 +115,15 @@ export const runStatus = async (
97
115
 
98
116
  log(`omd: ${basename}${format} — ${loaded.path}`);
99
117
  if (match) {
100
- const isLatestLabel = entry.isLatest === true
101
- ? ' (latest)'
102
- : entry.isLatest === false
103
- ? ` (behind: latest is ${latest ?? 'unknown'})`
104
- : '';
118
+ const isLatestLabel =
119
+ entry.isLatest === true
120
+ ? ' (latest)'
121
+ : entry.isLatest === false
122
+ ? ` (behind: latest is ${latest ?? 'unknown'})`
123
+ : '';
105
124
  log(` opencode-rules-md: ${match}${isLatestLabel}`);
106
125
  } else {
107
- log(` opencode-rules-md: not installed`);
126
+ log(' opencode-rules-md: not installed');
108
127
  }
109
128
 
110
129
  if (entry.otherPlugins.length > 0) {
@@ -119,13 +138,16 @@ export const runStatus = async (
119
138
 
120
139
  /**
121
140
  * Run the doctor command: health checks for the plugin environment.
122
- * - Node >= 20: issue if below
123
- * - Bun on PATH: issue if absent
124
- * - Configs readable: issue if absent or malformed
125
- * - Plugin shape valid: issue if malformed
126
- * - Rule dir existence: warning if absent
127
- * - Config dir writable: issue if not writable
128
- * - Package freshness: info line about update availability
141
+ *
142
+ * Checks:
143
+ * - Node >= 20: issue if below
144
+ * - Bun on PATH: issue if absent
145
+ * - Both configs readable and valid: issue if absent or malformed
146
+ * - Legacy `plugins` field present: warning (no-op for the runtime, but
147
+ * indicates a previous buggy install)
148
+ * - Rule dir existence: warning if absent
149
+ * - Config dir writable: issue if not writable
150
+ * - Package freshness: info line about update availability
129
151
  *
130
152
  * Exit 1 if any issues found.
131
153
  */
@@ -147,7 +169,7 @@ export const runDoctor = async (
147
169
  let hasBun = opts.hasBun;
148
170
  if (hasBun === undefined) {
149
171
  const pathEnv = (env.PATH ?? '').split(':');
150
- hasBun = pathEnv.some(p => {
172
+ hasBun = pathEnv.some((p) => {
151
173
  try {
152
174
  const { existsSync } = require('node:fs') as typeof import('node:fs');
153
175
  return existsSync(p + '/bun');
@@ -179,7 +201,7 @@ export const runDoctor = async (
179
201
  // ── Check: both configs readable and valid ─────────────────────────────────
180
202
  for (const basename of CONFIG_BASENAMES) {
181
203
  log(`Checking ${basename} config...`);
182
- const loaded = loadGlobalConfig(fs, env, basename);
204
+ const loaded: LoadedConfig = loadGlobalConfig(fs, env, basename);
183
205
 
184
206
  if (!loaded.exists) {
185
207
  warnings.push(`${basename} config not found at ${loaded.path}`);
@@ -188,23 +210,37 @@ export const runDoctor = async (
188
210
  }
189
211
 
190
212
  log(` ${basename}${extname(loaded.path)}: ${loaded.path}`);
191
- const plugins = normalizePlugin(loaded.data['plugins']);
192
- const match = plugins.find(p => matchesPlugin(p));
213
+ const plugins = readInstalledPlugins(loaded);
214
+ const match = plugins.find((p) => matchesPlugin(p)) ?? null;
193
215
 
194
216
  if (match) {
195
- info.push(`${basename}: opencode-rules-md@${match ?? 'unknown'} installed`);
217
+ info.push(`${basename}: opencode-rules-md installed (${match})`);
196
218
  log(` opencode-rules-md: ${match}`);
197
219
  } else {
198
220
  warnings.push(`${basename}: plugin not installed`);
199
- log(` opencode-rules-md: not installed`);
221
+ log(' opencode-rules-md: not installed');
222
+ }
223
+
224
+ // ── Check legacy `plugins` field (backward-compat warning) ──────────────
225
+ if (loaded.data['plugins'] !== undefined && loaded.data['plugin'] === undefined) {
226
+ warnings.push(
227
+ `${basename}: legacy "plugins" field present (should be "plugin") — run "omd uninstall && omd install" to migrate`,
228
+ );
229
+ log(` [WARN] ${basename}: legacy "plugins" field present (should be "plugin")`);
200
230
  }
201
231
 
202
232
  // ── Check plugin shape ────────────────────────────────────────────────────
203
- if (loaded.data['plugins'] !== undefined && !Array.isArray(loaded.data['plugins']) && typeof loaded.data['plugins'] !== 'object') {
204
- issues.push(`${basename}: plugins field has invalid type — expected array or object`);
233
+ const pluginField = loaded.data['plugin'] ?? loaded.data['plugins'];
234
+ if (
235
+ pluginField !== undefined &&
236
+ !Array.isArray(pluginField) &&
237
+ typeof pluginField !== 'object' &&
238
+ typeof pluginField !== 'string'
239
+ ) {
240
+ issues.push(`${basename}: plugin field has invalid type — expected array, object, or string`);
205
241
  error(`[ISSUE] ${basename}: invalid plugin shape`);
206
242
  } else {
207
- log(` plugins field: valid`);
243
+ log(' plugin field: valid');
208
244
  }
209
245
  }
210
246
 
@@ -218,6 +254,16 @@ export const runDoctor = async (
218
254
  log(` ${ruleBase}: exists`);
219
255
  }
220
256
 
257
+ // ── Check: package cache directory ────────────────────────────────────────
258
+ log('Checking OpenCode package cache...');
259
+ const cachePaths = resolveCachePaths(env, fs);
260
+ if (cachePaths.length === 0) {
261
+ info.push('No opencode-rules-md cache found under ~/.cache/opencode/packages/');
262
+ log(' no cache entries match opencode-rules-md*');
263
+ } else {
264
+ log(` cache entries: ${cachePaths.join(', ')}`);
265
+ }
266
+
221
267
  // ── Check: config dir writable ─────────────────────────────────────────────
222
268
  const configDir = env.OPENCODE_CONFIG_DIR ?? join(homedir(), '.config', 'opencode');
223
269
  log('Checking config directory write access...');
@@ -244,4 +290,4 @@ export const runDoctor = async (
244
290
  warnings,
245
291
  info,
246
292
  };
247
- };
293
+ };
@@ -1,28 +1,40 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // src/cli/uninstall.ts — `omd uninstall` command implementation.
3
3
  //
4
- // Dual-config uninstall: removes opencode-rules-md entries from both configs.
5
- // --purge removes ONLY ~/.cache/opencode/node_modules/opencode-rules-md and
6
- // NEVER touches rule directories.
4
+ // Removes opencode-rules-md from opencode.json and tui.json, and (with
5
+ // `--purge`) wipes the OpenCode package cache. Two important fixes vs.
6
+ // the previous implementation:
7
+ //
8
+ // 1. We read & write `data['plugin']` (singular), the field OpenCode
9
+ // actually honors. The previous code wrote `data['plugins']` (plural),
10
+ // which OpenCode silently ignored — so "uninstall" used to leave
11
+ // the plugin effectively installed.
12
+ // 2. We also strip a stale `data['plugins']` entry if it exists, so
13
+ // users who upgraded from a buggy `omd install` get their legacy
14
+ // field cleaned up too.
15
+ // 3. We purge ~/.cache/opencode/packages/opencode-rules-md* (the real
16
+ // cache OpenCode uses), not the old ~/.cache/opencode/node_modules
17
+ // path.
7
18
  // ---------------------------------------------------------------------------
8
19
 
9
- import { dirname, join } from 'path';
10
- import { homedir } from 'os';
20
+ import { join } from 'path';
11
21
  import {
12
22
  loadGlobalConfig,
13
- matchesPlugin,
14
- normalizePlugin,
15
23
  backupIfWritable,
16
24
  rotateBackups,
17
25
  writeAtomically,
18
26
  type CliFs,
19
27
  } from './config.js';
28
+ import { resolveCachePaths, purgeDirectory } from './update.js';
20
29
 
21
30
  export const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
22
31
 
23
32
  export interface UninstallOptions {
33
+ /** Also remove ~/.cache/opencode/packages/opencode-rules-md*. */
24
34
  purge?: boolean;
35
+ /** Run the full pipeline without writing to disk. */
25
36
  dryRun?: boolean;
37
+ /** Reserved for future prompts — no-op today. */
26
38
  yes?: boolean;
27
39
  }
28
40
 
@@ -38,13 +50,56 @@ export interface UninstallResult {
38
50
  purged: boolean;
39
51
  }
40
52
 
53
+ /**
54
+ * Strip `opencode-rules-md` entries from a config payload, cleaning both
55
+ * the modern `plugin` field (array or single string) and any legacy
56
+ * `plugins` field. Returns a tuple [newData, removedCount].
57
+ *
58
+ * `removedCount > 0` indicates the file actually needs to be rewritten.
59
+ */
60
+ function stripFromData(data: Record<string, unknown>): {
61
+ next: Record<string, unknown>;
62
+ removed: number;
63
+ } {
64
+ let removed = 0;
65
+ const next: Record<string, unknown> = { ...data };
66
+
67
+ // Modern field: `plugin` (singular). Accept array or single string.
68
+ const currentRaw = next['plugin'] ?? next['plugins'];
69
+ if (currentRaw !== undefined) {
70
+ if (typeof currentRaw === 'string') {
71
+ if (currentRaw.startsWith('opencode-rules-md')) {
72
+ delete next['plugin'];
73
+ delete next['plugins'];
74
+ removed += 1;
75
+ }
76
+ } else if (Array.isArray(currentRaw)) {
77
+ const filtered = (currentRaw as unknown[]).filter(
78
+ (p) => !(typeof p === 'string' && p.startsWith('opencode-rules-md')),
79
+ );
80
+ if (filtered.length !== currentRaw.length) {
81
+ removed += currentRaw.length - filtered.length;
82
+ if (filtered.length === 0) {
83
+ delete next['plugin'];
84
+ delete next['plugins'];
85
+ } else {
86
+ next['plugin'] = filtered;
87
+ delete next['plugins'];
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return { next, removed };
94
+ }
95
+
41
96
  /**
42
97
  * Uninstall opencode-rules-md from both opencode.json and tui.json configs.
43
98
  *
44
99
  * Options:
45
- * purge — also remove ~/.cache/opencode/node_modules/opencode-rules-md
100
+ * purge — also remove ~/.cache/opencode/packages/opencode-rules-md*
46
101
  * dryRun — run full pipeline without writing to disk
47
- * yes — accepted for future prompts, no-op here
102
+ * yes — reserved
48
103
  */
49
104
  export const runUninstall = (
50
105
  opts: UninstallOptions = {},
@@ -57,37 +112,15 @@ export const runUninstall = (
57
112
 
58
113
  // ── Purge cache if requested ────────────────────────────────────────────
59
114
  if (opts.purge) {
60
- const cachePath = join(
61
- homedir(),
62
- '.cache',
63
- 'opencode',
64
- 'node_modules',
65
- 'opencode-rules-md',
66
- );
67
- if (fs.existsSync(cachePath)) {
115
+ const cachePaths = resolveCachePaths(env, fs);
116
+ for (const cachePath of cachePaths) {
68
117
  try {
69
- // Check if it's a directory or a file
70
- const entries = fs.readdirSync(cachePath);
71
- if (entries.length === 0) {
72
- // Empty directory — remove it
73
- fs.rmdirSync(cachePath);
74
- } else {
75
- // Has contents — remove contents then the dir
76
- for (const entry of entries) {
77
- const entryPath = join(cachePath, entry);
78
- if (fs.existsSync(entryPath)) {
79
- try {
80
- fs.unlinkSync(entryPath);
81
- } catch {
82
- // ignore individual file failures
83
- }
84
- }
85
- }
86
- fs.rmdirSync(cachePath);
118
+ if (fs.existsSync(cachePath)) {
119
+ purgeDirectory(fs, cachePath);
120
+ purged = true;
87
121
  }
88
- purged = true;
89
122
  } catch {
90
- // best-effort — cache purge failure is non-fatal
123
+ // best-effort — purge failure is non-fatal
91
124
  }
92
125
  }
93
126
  }
@@ -95,39 +128,37 @@ export const runUninstall = (
95
128
  // ── Remove plugin from both configs ─────────────────────────────────────
96
129
  for (const basename of CONFIG_BASENAMES) {
97
130
  const loaded = loadGlobalConfig(fs, env, basename);
98
- const plugins = normalizePlugin(loaded.data['plugins']);
99
131
 
100
- // Filter out all opencode-rules-md entries
101
- const remaining = plugins.filter(p => !matchesPlugin(p));
132
+ if (!loaded.exists) {
133
+ results.push({ path: loaded.path, status: 'skipped', backup: null });
134
+ continue;
135
+ }
102
136
 
103
- if (remaining.length === plugins.length) {
104
- // Nothing to remove — no-op for this file
137
+ const { next, removed } = stripFromData(loaded.data);
138
+
139
+ if (removed === 0) {
105
140
  results.push({ path: loaded.path, status: 'skipped', backup: null });
106
141
  continue;
107
142
  }
108
143
 
109
144
  anyProcessed = true;
110
145
 
111
- const newData = { ...loaded.data, plugins: remaining };
112
- const newContent = JSON.stringify(newData, null, 2) + '\n';
146
+ const newContent = JSON.stringify(next, null, 2) + '\n';
113
147
 
114
148
  if (opts.dryRun) {
115
149
  results.push({ path: loaded.path, status: 'wrote', backup: null });
116
150
  continue;
117
151
  }
118
152
 
119
- // Backup the existing file if it exists
120
- let backup: string | undefined;
121
- if (loaded.exists) {
122
- backup = backupIfWritable(fs, loaded.path);
123
- if (backup !== undefined) {
124
- const dir = dirname(loaded.path);
125
- const segs = loaded.path.replace(/\\/g, '/').split('/');
126
- const base = segs[segs.length - 1] ?? loaded.path;
127
- const dot = base.lastIndexOf('.');
128
- const name = dot >= 0 ? base.slice(0, dot) : base;
129
- rotateBackups(fs, dir, name, 3);
130
- }
153
+ // Backup the existing file before rewriting.
154
+ const backup = backupIfWritable(fs, loaded.path);
155
+ if (backup !== undefined) {
156
+ const segs = loaded.path.replace(/\\/g, '/').split('/');
157
+ const base = segs[segs.length - 1] ?? loaded.path;
158
+ const dot = base.lastIndexOf('.');
159
+ const name = dot >= 0 ? base.slice(0, dot) : base;
160
+ const dir = join(...segs.slice(0, -1));
161
+ rotateBackups(fs, dir, name, 3);
131
162
  }
132
163
 
133
164
  writeAtomically(fs, loaded.path, newContent);
@@ -143,4 +174,4 @@ export const runUninstall = (
143
174
  results,
144
175
  purged,
145
176
  };
146
- };
177
+ };