opencode-rules-md 0.8.4 → 0.8.5
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/dist/cli.mjs +915 -0
- package/dist/src/cli/config.d.ts +93 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +382 -0
- package/dist/src/cli/config.js.map +1 -0
- package/dist/src/cli/install.d.ts +35 -0
- package/dist/src/cli/install.d.ts.map +1 -0
- package/dist/src/cli/install.js +148 -0
- package/dist/src/cli/install.js.map +1 -0
- package/dist/src/cli/main.d.ts +14 -0
- package/dist/src/cli/main.d.ts.map +1 -0
- package/dist/src/cli/main.js +244 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/real-fs.d.ts +3 -0
- package/dist/src/cli/real-fs.d.ts.map +1 -0
- package/dist/src/cli/real-fs.js +32 -0
- package/dist/src/cli/real-fs.js.map +1 -0
- package/dist/src/cli/registry.d.ts +12 -0
- package/dist/src/cli/registry.d.ts.map +1 -0
- package/dist/src/cli/registry.js +34 -0
- package/dist/src/cli/registry.js.map +1 -0
- package/dist/src/cli/status.d.ts +44 -0
- package/dist/src/cli/status.d.ts.map +1 -0
- package/dist/src/cli/status.js +182 -0
- package/dist/src/cli/status.js.map +1 -0
- package/dist/src/cli/uninstall.d.ts +27 -0
- package/dist/src/cli/uninstall.d.ts.map +1 -0
- package/dist/src/cli/uninstall.js +101 -0
- package/dist/src/cli/uninstall.js.map +1 -0
- package/dist/src/cli/update.d.ts +31 -0
- package/dist/src/cli/update.d.ts.map +1 -0
- package/dist/src/cli/update.js +173 -0
- package/dist/src/cli/update.js.map +1 -0
- package/package.json +7 -2
- package/src/cli/config.ts +461 -0
- package/src/cli/install.ts +211 -0
- package/src/cli/main.ts +299 -0
- package/src/cli/real-fs.ts +44 -0
- package/src/cli/registry.ts +36 -0
- package/src/cli/status.ts +247 -0
- package/src/cli/uninstall.ts +146 -0
- package/src/cli/update.ts +220 -0
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// src/cli/main.ts — `omd` CLI entry point.
|
|
3
|
+
//
|
|
4
|
+
// Command dispatch using node:util.parseArgs.
|
|
5
|
+
// Bare `omd` → install (default).
|
|
6
|
+
// Exit codes: 0 success/no-op, 1 health failure, 2 usage error.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { runInstall, type InstallOptions } from './install.js';
|
|
11
|
+
import { runUninstall, type UninstallOptions } from './uninstall.js';
|
|
12
|
+
import { runStatus, runDoctor } from './status.js';
|
|
13
|
+
import { runUpdate } from './update.js';
|
|
14
|
+
import { createRealFs } from './real-fs.js';
|
|
15
|
+
import type { CliFs } from './config.js';
|
|
16
|
+
|
|
17
|
+
// ─── Usage ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const USAGE = `omd — opencode-rules-md plugin manager
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
omd [command] [options]
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
install Register opencode-rules-md in both opencode.json and tui.json
|
|
26
|
+
uninstall Remove opencode-rules-md from both configs
|
|
27
|
+
status Show installed plugin state for each config
|
|
28
|
+
doctor Run health checks for the plugin environment
|
|
29
|
+
update Check for new versions and purge stale cache
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--dry-run Show what would be changed without writing
|
|
33
|
+
--version Pin to a specific version (install only)
|
|
34
|
+
--latest Use the latest version (install only)
|
|
35
|
+
--purge Also remove ~/.cache/opencode/node_modules/opencode-rules-md (uninstall only)
|
|
36
|
+
--yes Accept all prompts automatically
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
omd # install with defaults (latest)
|
|
40
|
+
omd install # same as bare omd
|
|
41
|
+
omd install --dry-run
|
|
42
|
+
omd install --version 2.0.0
|
|
43
|
+
omd uninstall --purge
|
|
44
|
+
`.trim();
|
|
45
|
+
|
|
46
|
+
const printUsage = (stdout: (s: string) => void): void => {
|
|
47
|
+
stdout(USAGE);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ─── Manual argv parsing — extract command before passing to parseArgs ────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Known option flags (long-form).
|
|
54
|
+
* We detect unknown flags by scanning argv for anything starting with `-` or `--`
|
|
55
|
+
* that is NOT in this set.
|
|
56
|
+
*/
|
|
57
|
+
const KNOWN_FLAGS = new Set([
|
|
58
|
+
'--help', '--dry-run', '--latest', '--purge', '--yes', '--version',
|
|
59
|
+
'-h', '-V',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Known short options that take a value.
|
|
64
|
+
* e.g. `--version 2.0.0` → values.version = '2.0.0'
|
|
65
|
+
*/
|
|
66
|
+
const SHORT_OPTIONS_WITH_VALUE = new Set(['v']);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Known long options that take a value.
|
|
70
|
+
*/
|
|
71
|
+
const LONG_OPTIONS_WITH_VALUE = new Set(['version']);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract the command name (first non-option positional) from argv.
|
|
75
|
+
* Also returns unknown flags found before the command.
|
|
76
|
+
*
|
|
77
|
+
* Examples:
|
|
78
|
+
* [] → { command: 'install', args: [], unknownFlags: [] }
|
|
79
|
+
* ['install'] → { command: 'install', args: [], unknownFlags: [] }
|
|
80
|
+
* ['notacommand'] → { command: 'notacommand', args: [], unknownFlags: [] }
|
|
81
|
+
* ['install', '--dry-run'] → { command: 'install', args: ['--dry-run'], unknownFlags: [] }
|
|
82
|
+
* ['install', '--unknown-opt'] → { command: 'install', args: ['--unknown-opt'], unknownFlags: ['--unknown-opt'] }
|
|
83
|
+
*/
|
|
84
|
+
function extractCommand(
|
|
85
|
+
argv: string[],
|
|
86
|
+
): { command: string | null; unknownFlags: string[] } {
|
|
87
|
+
const unknownFlags: string[] = [];
|
|
88
|
+
let command: string | null = null;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const arg = argv[i]!;
|
|
92
|
+
|
|
93
|
+
if (arg === '--') {
|
|
94
|
+
// Argument separator — remaining args are positional
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!arg.startsWith('-')) {
|
|
99
|
+
// First non-option is the command
|
|
100
|
+
if (command === null) {
|
|
101
|
+
command = arg;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// It's an option — check if known
|
|
107
|
+
if (!KNOWN_FLAGS.has(arg)) {
|
|
108
|
+
unknownFlags.push(arg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Skip the value for options that take one
|
|
112
|
+
const flagName = arg.startsWith('--')
|
|
113
|
+
? arg.slice(2)
|
|
114
|
+
: arg.startsWith('-')
|
|
115
|
+
? arg.slice(1)
|
|
116
|
+
: '';
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
LONG_OPTIONS_WITH_VALUE.has(flagName) ||
|
|
120
|
+
SHORT_OPTIONS_WITH_VALUE.has(flagName)
|
|
121
|
+
) {
|
|
122
|
+
// Skip the next argv element if it's not an option itself
|
|
123
|
+
if (i + 1 < argv.length && !argv[i + 1]!.startsWith('-')) {
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { command, unknownFlags };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Main dispatch ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export interface MainOptions {
|
|
135
|
+
fs?: CliFs;
|
|
136
|
+
env?: NodeJS.ProcessEnv;
|
|
137
|
+
stdout?: (s: string) => void;
|
|
138
|
+
stderr?: (s: string) => void;
|
|
139
|
+
/**
|
|
140
|
+
* Optional injection point for tests: pretend the npm registry returned this
|
|
141
|
+
* version when "latest" is requested by install or update.
|
|
142
|
+
*/
|
|
143
|
+
latestVersion?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const runMain = async (
|
|
147
|
+
opts: MainOptions,
|
|
148
|
+
argv: string[],
|
|
149
|
+
): Promise<number> => {
|
|
150
|
+
const {
|
|
151
|
+
fs = createRealFs(),
|
|
152
|
+
env = process.env,
|
|
153
|
+
stdout = (s: string) => console.log(s),
|
|
154
|
+
stderr = (s: string) => console.error(s),
|
|
155
|
+
} = opts;
|
|
156
|
+
|
|
157
|
+
// Intercept --help/-h before anything else
|
|
158
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
159
|
+
printUsage(stdout);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Extract command and detect unknown flags before parseArgs
|
|
164
|
+
const { command, unknownFlags } = extractCommand(argv);
|
|
165
|
+
|
|
166
|
+
if (unknownFlags.length > 0) {
|
|
167
|
+
stderr(`omd: unknown option ${unknownFlags[0]}`);
|
|
168
|
+
stderr("Run 'omd --help' for usage.");
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Default to install for bare `omd`
|
|
173
|
+
const resolvedCommand = command ?? 'install';
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
switch (resolvedCommand) {
|
|
177
|
+
case 'install': {
|
|
178
|
+
// Remaining argv for parseArgs to extract options
|
|
179
|
+
const remaining = argv.slice(
|
|
180
|
+
argv.indexOf('install') + 1 || argv.indexOf(resolvedCommand) + 1,
|
|
181
|
+
);
|
|
182
|
+
const { values } = parseArgs({
|
|
183
|
+
argv: remaining,
|
|
184
|
+
allowPositionals: true,
|
|
185
|
+
strict: false,
|
|
186
|
+
options: {
|
|
187
|
+
'dry-run': { type: 'boolean', default: false },
|
|
188
|
+
version: { type: 'string', default: undefined },
|
|
189
|
+
latest: { type: 'boolean', default: false },
|
|
190
|
+
yes: { type: 'boolean', default: false },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
const installOpts: InstallOptions = {
|
|
194
|
+
version: values['latest'] ? 'latest' : String(values['version'] ?? ''),
|
|
195
|
+
dryRun: Boolean(values['dry-run']),
|
|
196
|
+
yes: Boolean(values['yes']),
|
|
197
|
+
latestVersion: opts.latestVersion,
|
|
198
|
+
};
|
|
199
|
+
const result = await runInstall(installOpts, fs, env);
|
|
200
|
+
if (result.status === 'skipped') {
|
|
201
|
+
stdout('omd: already installed (no changes needed)');
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
for (const r of result.results) {
|
|
205
|
+
if (r.status === 'wrote') {
|
|
206
|
+
stdout(`omd: registered in ${r.path}`);
|
|
207
|
+
} else if (r.status === 'skipped') {
|
|
208
|
+
stdout(`omd: ${r.path} — already up to date`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (result.purged) {
|
|
212
|
+
stdout('omd: cache purged');
|
|
213
|
+
}
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'uninstall': {
|
|
218
|
+
const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
|
|
219
|
+
const { values } = parseArgs({
|
|
220
|
+
argv: remaining,
|
|
221
|
+
allowPositionals: true,
|
|
222
|
+
strict: false,
|
|
223
|
+
options: {
|
|
224
|
+
'dry-run': { type: 'boolean', default: false },
|
|
225
|
+
purge: { type: 'boolean', default: false },
|
|
226
|
+
yes: { type: 'boolean', default: false },
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const uninstallOpts: UninstallOptions = {
|
|
230
|
+
purge: Boolean(values['purge']),
|
|
231
|
+
dryRun: Boolean(values['dry-run']),
|
|
232
|
+
yes: Boolean(values['yes']),
|
|
233
|
+
};
|
|
234
|
+
const result = runUninstall(uninstallOpts, fs, env);
|
|
235
|
+
if (result.status === 'skipped') {
|
|
236
|
+
stdout('omd: not installed (no changes needed)');
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
for (const r of result.results) {
|
|
240
|
+
if (r.status === 'wrote') {
|
|
241
|
+
stdout(`omd: removed from ${r.path}`);
|
|
242
|
+
} else if (r.status === 'skipped') {
|
|
243
|
+
stdout(`omd: ${r.path} — not present`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (result.purged) {
|
|
247
|
+
stdout('omd: cache purged');
|
|
248
|
+
}
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'status': {
|
|
253
|
+
await runStatus(fs, env, stdout);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
case 'doctor': {
|
|
258
|
+
const docResult = await runDoctor(fs, env, stdout, stderr);
|
|
259
|
+
return docResult.ok ? 0 : 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'update': {
|
|
263
|
+
const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
|
|
264
|
+
const { values } = parseArgs({
|
|
265
|
+
argv: remaining,
|
|
266
|
+
allowPositionals: true,
|
|
267
|
+
strict: false,
|
|
268
|
+
options: {
|
|
269
|
+
'dry-run': { type: 'boolean', default: false },
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const dryRun = Boolean(values['dry-run']);
|
|
273
|
+
const updateResult = await runUpdate(fs, env, stdout, stderr, { dryRun, latestVersion: opts.latestVersion });
|
|
274
|
+
if (updateResult.status === 'current') {
|
|
275
|
+
stdout('omd: already at latest version');
|
|
276
|
+
}
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
default:
|
|
281
|
+
stderr(`omd: unknown command '${resolvedCommand}'`);
|
|
282
|
+
stderr("Run 'omd --help' for usage.");
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
stderr(`omd: ${(err as Error).message}`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
292
|
+
// Only run when executed directly (not imported as a module in tests).
|
|
293
|
+
const isMainModule =
|
|
294
|
+
import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`;
|
|
295
|
+
|
|
296
|
+
if (isMainModule) {
|
|
297
|
+
const exitCode = await runMain({}, process.argv.slice(2));
|
|
298
|
+
process.exit(exitCode);
|
|
299
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// src/cli/real-fs.ts — Default `CliFs` adapter backed by `node:fs`.
|
|
3
|
+
//
|
|
4
|
+
// The CLI commands default to the real filesystem in production. Tests inject
|
|
5
|
+
// an in-memory adapter to keep everything deterministic and fast. All methods
|
|
6
|
+
// are sync — the CLI is short-lived and never benefits from async I/O.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
copyFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmdirSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import type { CliFs } from "./config";
|
|
21
|
+
|
|
22
|
+
export const createRealFs = (): CliFs => ({
|
|
23
|
+
readFileSync: (path) => readFileSync(path, "utf8"),
|
|
24
|
+
writeFileSync: (path, content) => {
|
|
25
|
+
writeFileSync(path, content);
|
|
26
|
+
},
|
|
27
|
+
renameSync: (from, to) => {
|
|
28
|
+
renameSync(from, to);
|
|
29
|
+
},
|
|
30
|
+
copyFileSync: (from, to) => {
|
|
31
|
+
copyFileSync(from, to);
|
|
32
|
+
},
|
|
33
|
+
unlinkSync: (path) => {
|
|
34
|
+
unlinkSync(path);
|
|
35
|
+
},
|
|
36
|
+
mkdirSync: (path, opts) => {
|
|
37
|
+
mkdirSync(path, opts);
|
|
38
|
+
},
|
|
39
|
+
readdirSync: (path) => readdirSync(path),
|
|
40
|
+
existsSync: (path) => existsSync(path),
|
|
41
|
+
rmdirSync: (path) => {
|
|
42
|
+
rmdirSync(path);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// src/cli/registry.ts — npm registry helpers for `omd`.
|
|
3
|
+
//
|
|
4
|
+
// Fetches the latest published version of `opencode-rules-md` from the npm
|
|
5
|
+
// registry. Uses the native `fetch()` API (Node 20+) — no extra dependencies.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const REGISTRY_URL = "https://registry.npmjs.org/opencode-rules-md/latest";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fetch the latest version string from the npm registry.
|
|
12
|
+
* Returns `null` when the registry is unreachable or the response is
|
|
13
|
+
* malformed — callers treat `null` as "can't determine, don't block".
|
|
14
|
+
*/
|
|
15
|
+
export const fetchLatestVersion = async (): Promise<string | null> => {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(REGISTRY_URL);
|
|
18
|
+
if (!res.ok) return null;
|
|
19
|
+
const data = (await res.json()) as { version?: string };
|
|
20
|
+
return data.version ?? null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return whether the installed version is older than the latest.
|
|
28
|
+
* Unknown versions (null on either side) are never treated as stale.
|
|
29
|
+
*/
|
|
30
|
+
export const isStale = (
|
|
31
|
+
installed: string | null,
|
|
32
|
+
latest: string | null,
|
|
33
|
+
): boolean => {
|
|
34
|
+
if (!installed || !latest) return false;
|
|
35
|
+
return installed !== latest;
|
|
36
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// src/cli/status.ts — `omd status` and `omd doctor` command implementations.
|
|
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.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { extname } from 'path';
|
|
12
|
+
import {
|
|
13
|
+
loadGlobalConfig,
|
|
14
|
+
matchesPlugin,
|
|
15
|
+
normalizePlugin,
|
|
16
|
+
type CliFs,
|
|
17
|
+
} from './config.js';
|
|
18
|
+
import { fetchLatestVersion } from './registry.js';
|
|
19
|
+
|
|
20
|
+
// ─── Status types ─────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface StatusEntry {
|
|
23
|
+
basename: string;
|
|
24
|
+
path: string;
|
|
25
|
+
format: string;
|
|
26
|
+
installed: string | null;
|
|
27
|
+
notInstalled?: boolean;
|
|
28
|
+
otherPlugins: string[];
|
|
29
|
+
latest: string | null;
|
|
30
|
+
isLatest: boolean | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StatusResult {
|
|
34
|
+
configs: StatusEntry[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Doctor types ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface DoctorOptions {
|
|
40
|
+
nodeVersion?: string;
|
|
41
|
+
hasBun?: boolean;
|
|
42
|
+
ruleDirExists?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DoctorResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
issues: string[];
|
|
48
|
+
warnings: string[];
|
|
49
|
+
info: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const RULE_DIR_NAME = 'opencode-rules-md';
|
|
55
|
+
const MIN_NODE_VERSION = 20;
|
|
56
|
+
|
|
57
|
+
const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
|
|
58
|
+
|
|
59
|
+
// ─── runStatus ────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the status command: read-only probe for both opencode.json and tui.json.
|
|
63
|
+
* Reports path, format, installed specifier, other plugins, and freshness.
|
|
64
|
+
*/
|
|
65
|
+
export const runStatus = async (
|
|
66
|
+
fs: CliFs,
|
|
67
|
+
env: NodeJS.ProcessEnv,
|
|
68
|
+
log: (s: string) => void,
|
|
69
|
+
): Promise<StatusResult> => {
|
|
70
|
+
const configs: StatusEntry[] = [];
|
|
71
|
+
const latest = await fetchLatestVersion();
|
|
72
|
+
|
|
73
|
+
for (const basename of CONFIG_BASENAMES) {
|
|
74
|
+
const loaded = loadGlobalConfig(fs, env, basename);
|
|
75
|
+
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));
|
|
78
|
+
|
|
79
|
+
const entry: StatusEntry = {
|
|
80
|
+
basename,
|
|
81
|
+
path: loaded.path,
|
|
82
|
+
format,
|
|
83
|
+
installed: match ?? null,
|
|
84
|
+
notInstalled: !loaded.exists ? true : match === undefined || match === null,
|
|
85
|
+
otherPlugins: plugins.filter(p => !matchesPlugin(p)),
|
|
86
|
+
latest,
|
|
87
|
+
isLatest: match !== undefined && latest !== null ? match === `opencode-rules-md@${latest}` : null,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
configs.push(entry);
|
|
91
|
+
|
|
92
|
+
// ── Print status lines ──────────────────────────────────────────────────
|
|
93
|
+
if (!loaded.exists) {
|
|
94
|
+
log(`omd: ${basename}.json — config not found at ${loaded.path}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log(`omd: ${basename}${format} — ${loaded.path}`);
|
|
99
|
+
if (match) {
|
|
100
|
+
const isLatestLabel = entry.isLatest === true
|
|
101
|
+
? ' (latest)'
|
|
102
|
+
: entry.isLatest === false
|
|
103
|
+
? ` (behind: latest is ${latest ?? 'unknown'})`
|
|
104
|
+
: '';
|
|
105
|
+
log(` opencode-rules-md: ${match}${isLatestLabel}`);
|
|
106
|
+
} else {
|
|
107
|
+
log(` opencode-rules-md: not installed`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (entry.otherPlugins.length > 0) {
|
|
111
|
+
log(` other plugins: ${entry.otherPlugins.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { configs };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// ─── runDoctor ────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 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
|
|
129
|
+
*
|
|
130
|
+
* Exit 1 if any issues found.
|
|
131
|
+
*/
|
|
132
|
+
export const runDoctor = async (
|
|
133
|
+
fs: CliFs,
|
|
134
|
+
env: NodeJS.ProcessEnv,
|
|
135
|
+
log: (s: string) => void,
|
|
136
|
+
error: (s: string) => void,
|
|
137
|
+
opts: DoctorOptions = {},
|
|
138
|
+
): Promise<DoctorResult> => {
|
|
139
|
+
const issues: string[] = [];
|
|
140
|
+
const warnings: string[] = [];
|
|
141
|
+
const info: string[] = [];
|
|
142
|
+
|
|
143
|
+
const nodeVersion = opts.nodeVersion ?? process.version.slice(1); // strip 'v'
|
|
144
|
+
const ruleDirExists = opts.ruleDirExists ?? true;
|
|
145
|
+
|
|
146
|
+
// Check if Bun is available: use override if provided, otherwise scan PATH
|
|
147
|
+
let hasBun = opts.hasBun;
|
|
148
|
+
if (hasBun === undefined) {
|
|
149
|
+
const pathEnv = (env.PATH ?? '').split(':');
|
|
150
|
+
hasBun = pathEnv.some(p => {
|
|
151
|
+
try {
|
|
152
|
+
const { existsSync } = require('node:fs') as typeof import('node:fs');
|
|
153
|
+
return existsSync(p + '/bun');
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Check: Node version ───────────────────────────────────────────────────
|
|
161
|
+
log('Checking Node version...');
|
|
162
|
+
const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
|
|
163
|
+
if (nodeMajor < MIN_NODE_VERSION) {
|
|
164
|
+
issues.push(`Node.js ${nodeVersion} is too old; requires Node >= ${MIN_NODE_VERSION}`);
|
|
165
|
+
error(`[ISSUE] Node.js ${nodeVersion} — requires >= ${MIN_NODE_VERSION}. Download latest from https://nodejs.org`);
|
|
166
|
+
} else {
|
|
167
|
+
log(` Node.js ${nodeVersion} — OK`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Check: Bun on PATH ─────────────────────────────────────────────────────
|
|
171
|
+
log('Checking for Bun...');
|
|
172
|
+
if (!hasBun) {
|
|
173
|
+
issues.push('Bun is not on PATH — install from https://bun.sh');
|
|
174
|
+
error('[ISSUE] Bun not found on PATH — install Bun for best performance');
|
|
175
|
+
} else {
|
|
176
|
+
log(' Bun — found on PATH');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Check: both configs readable and valid ─────────────────────────────────
|
|
180
|
+
for (const basename of CONFIG_BASENAMES) {
|
|
181
|
+
log(`Checking ${basename} config...`);
|
|
182
|
+
const loaded = loadGlobalConfig(fs, env, basename);
|
|
183
|
+
|
|
184
|
+
if (!loaded.exists) {
|
|
185
|
+
warnings.push(`${basename} config not found at ${loaded.path}`);
|
|
186
|
+
log(` ${basename}: not found (will be created on first install)`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log(` ${basename}${extname(loaded.path)}: ${loaded.path}`);
|
|
191
|
+
const plugins = normalizePlugin(loaded.data['plugins']);
|
|
192
|
+
const match = plugins.find(p => matchesPlugin(p));
|
|
193
|
+
|
|
194
|
+
if (match) {
|
|
195
|
+
info.push(`${basename}: opencode-rules-md@${match ?? 'unknown'} installed`);
|
|
196
|
+
log(` opencode-rules-md: ${match}`);
|
|
197
|
+
} else {
|
|
198
|
+
warnings.push(`${basename}: plugin not installed`);
|
|
199
|
+
log(` opencode-rules-md: not installed`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 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`);
|
|
205
|
+
error(`[ISSUE] ${basename}: invalid plugin shape`);
|
|
206
|
+
} else {
|
|
207
|
+
log(` plugins field: valid`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Check: rule dir existence (warning, not issue) ─────────────────────────
|
|
212
|
+
log('Checking rule directory...');
|
|
213
|
+
const ruleBase = join(homedir(), '.local', 'share', RULE_DIR_NAME);
|
|
214
|
+
if (!ruleDirExists) {
|
|
215
|
+
warnings.push(`Rule directory not found at ${ruleBase}`);
|
|
216
|
+
log(` ${ruleBase}: not found (plugin rules not installed — this is optional)`);
|
|
217
|
+
} else {
|
|
218
|
+
log(` ${ruleBase}: exists`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Check: config dir writable ─────────────────────────────────────────────
|
|
222
|
+
const configDir = env.OPENCODE_CONFIG_DIR ?? join(homedir(), '.config', 'opencode');
|
|
223
|
+
log('Checking config directory write access...');
|
|
224
|
+
const parentDir = dirname(configDir);
|
|
225
|
+
if (!fs.existsSync(parentDir)) {
|
|
226
|
+
issues.push(`Config parent directory does not exist: ${parentDir}`);
|
|
227
|
+
error(`[ISSUE] Config dir parent missing: ${parentDir}`);
|
|
228
|
+
} else {
|
|
229
|
+
log(` ${parentDir}: writable`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
233
|
+
log('');
|
|
234
|
+
if (issues.length === 0) {
|
|
235
|
+
log('omd doctor: all checks passed ✓');
|
|
236
|
+
} else {
|
|
237
|
+
error(`omd doctor: ${issues.length} issue(s) found — fix before using the plugin`);
|
|
238
|
+
error(`Run 'omd --help' for usage information`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
ok: issues.length === 0,
|
|
243
|
+
issues,
|
|
244
|
+
warnings,
|
|
245
|
+
info,
|
|
246
|
+
};
|
|
247
|
+
};
|