opencode-rules-md 0.8.3 → 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 +11 -15
- 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
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/cli/config.ts
|
|
3
|
+
*
|
|
4
|
+
* Config helpers for the omd CLI installer.
|
|
5
|
+
* Exports: parseJsonc, resolveConfigPath, normalizePlugin, matchesPlugin,
|
|
6
|
+
* dedupePlugins, buildSpecifier, backupIfWritable, rotateBackups,
|
|
7
|
+
* writeAtomically, loadGlobalConfig.
|
|
8
|
+
*
|
|
9
|
+
* Constants:
|
|
10
|
+
* PLUGIN_NAME = "opencode-rules-md"
|
|
11
|
+
* BACKUP_LIMIT = 3
|
|
12
|
+
* OPENCODE_CONFIG_SUBDIR = "opencode"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Constants
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const PLUGIN_NAME = 'opencode-rules-md' as const;
|
|
23
|
+
export const BACKUP_LIMIT = 3;
|
|
24
|
+
export const OPENCODE_CONFIG_SUBDIR = 'opencode';
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// CliFs interface
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface CliFs {
|
|
31
|
+
readFileSync(path: string): string;
|
|
32
|
+
writeFileSync(path: string, content: string): void;
|
|
33
|
+
renameSync(from: string, to: string): void;
|
|
34
|
+
copyFileSync(from: string, to: string): void;
|
|
35
|
+
unlinkSync(path: string): void;
|
|
36
|
+
mkdirSync(path: string, opts?: { recursive?: boolean }): void;
|
|
37
|
+
readdirSync(path: string): string[];
|
|
38
|
+
existsSync(path: string): boolean;
|
|
39
|
+
rmdirSync(path: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// parseJsonc
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse JSONC (JSON with Comments) content.
|
|
48
|
+
* Strips single-line and multi-line comments and trailing commas.
|
|
49
|
+
* Returns an empty object for empty/whitespace-only input.
|
|
50
|
+
* Throws if the stripped content is not valid JSON.
|
|
51
|
+
*/
|
|
52
|
+
export function parseJsonc(content: string): Record<string, unknown> {
|
|
53
|
+
if (content.trim() === '') {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// String-aware comment stripping with bracket-depth tracking.
|
|
58
|
+
let out = '';
|
|
59
|
+
let i = 0;
|
|
60
|
+
// Bracket depth outside strings: +1 for {, -1 for }, +1 for [, -1 for ].
|
|
61
|
+
let depth = 0;
|
|
62
|
+
|
|
63
|
+
while (i < content.length) {
|
|
64
|
+
const ch = content[i]!;
|
|
65
|
+
|
|
66
|
+
// Start of // comment — strip until newline or end-of-string.
|
|
67
|
+
if (ch === '/' && content[i + 1] === '/' && !isInsideString(out)) {
|
|
68
|
+
let j = i + 2;
|
|
69
|
+
while (j < content.length && content[j] !== '\n') {
|
|
70
|
+
j++;
|
|
71
|
+
}
|
|
72
|
+
out += ' '; // replace the comment with a single space
|
|
73
|
+
// If we stopped at a newline (not EOF): skip the newline — it is the
|
|
74
|
+
// comment terminator, not JSON content. Set i to j so the outer loop
|
|
75
|
+
// increments past the newline without adding it.
|
|
76
|
+
if (j < content.length && content[j] === '\n') {
|
|
77
|
+
i = j; // outer i++ lands on j (the newline), then increments to j+1
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// EOF (j >= content.length): the comment runs to end of file.
|
|
81
|
+
// Preserve a trailing } or ] at EOF as it is likely the JSON closer.
|
|
82
|
+
if (j >= content.length) {
|
|
83
|
+
const last = content[content.length - 1]!;
|
|
84
|
+
if (last === '}' || last === ']') {
|
|
85
|
+
out += last;
|
|
86
|
+
}
|
|
87
|
+
i = j;
|
|
88
|
+
} else {
|
|
89
|
+
i = j - 1; // outer i++ will land on j (EOF terminator position)
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Start of /* */ comment — strip the whole span
|
|
95
|
+
if (ch === '/' && content[i + 1] === '*' && !isInsideString(out)) {
|
|
96
|
+
let j = i + 2;
|
|
97
|
+
while (j < content.length - 1) {
|
|
98
|
+
if (content[j] === '*' && content[j + 1] === '/') {
|
|
99
|
+
j += 2;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
j++;
|
|
103
|
+
}
|
|
104
|
+
out += ' ';
|
|
105
|
+
i = j;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Track bracket depth (outside strings)
|
|
110
|
+
if (!isInsideString(out)) {
|
|
111
|
+
if (ch === '{') depth++;
|
|
112
|
+
else if (ch === '}') depth = Math.max(0, depth - 1);
|
|
113
|
+
else if (ch === '[') depth++;
|
|
114
|
+
else if (ch === ']') depth = Math.max(0, depth - 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
out += ch;
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Phase 2a: strip trailing commas before ] or }
|
|
122
|
+
let s = out.replace(/,(\s*[}\]])/g, '$1');
|
|
123
|
+
|
|
124
|
+
// Phase 2b: if at root level (depth > 0 means a } was consumed as comment text
|
|
125
|
+
// at EOF) and s has no closing } or ], add it and strip any trailing comma.
|
|
126
|
+
if (depth > 0 && !/[}\]]/.test(s)) {
|
|
127
|
+
s = s.replace(/,(\s*$)/, '') + '}';
|
|
128
|
+
depth = 0; // we repaired it
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (s.trim() === '') {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(s) as Record<string, unknown>;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const msg = (err as Error).message;
|
|
139
|
+
throw new Error('parseJsonc: invalid JSON after stripping comments - ' + msg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True if the number of unescaped double or single quotes in `s` is odd. */
|
|
144
|
+
function isInsideString(s: string): boolean {
|
|
145
|
+
let inStr = false;
|
|
146
|
+
let strChar = '';
|
|
147
|
+
for (let i = 0; i < s.length; i++) {
|
|
148
|
+
const ch = s[i]!;
|
|
149
|
+
if (!inStr && (ch === '"' || ch === "'")) {
|
|
150
|
+
inStr = true;
|
|
151
|
+
strChar = ch;
|
|
152
|
+
} else if (inStr && ch === '\\') {
|
|
153
|
+
i++; // skip escaped char
|
|
154
|
+
} else if (inStr && ch === strChar) {
|
|
155
|
+
inStr = false;
|
|
156
|
+
strChar = '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return inStr;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// resolveConfigDir / resolveConfigPath
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function resolveConfigDir(env: NodeJS.ProcessEnv): string {
|
|
167
|
+
const custom = env.OPENCODE_CONFIG_DIR;
|
|
168
|
+
if (custom && custom.trim() !== '') {
|
|
169
|
+
return custom;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Honor the XDG Base Directory Specification when it is set.
|
|
173
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
174
|
+
if (xdg && xdg.trim() !== '') {
|
|
175
|
+
return join(xdg, OPENCODE_CONFIG_SUBDIR);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fall back to $HOME/.config/opencode before using os.homedir().
|
|
179
|
+
// This keeps tests that control HOME deterministic and avoids loading the
|
|
180
|
+
// wrong global config when the process environment is customized.
|
|
181
|
+
const home = env.HOME;
|
|
182
|
+
if (home && home.trim() !== '') {
|
|
183
|
+
return join(home, '.config', OPENCODE_CONFIG_SUBDIR);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return join(homedir(), '.config', OPENCODE_CONFIG_SUBDIR);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve the config path for a given basename.
|
|
191
|
+
* Prefers .json over .jsonc; returns { path, exists }.
|
|
192
|
+
* The path does NOT need to exist — absent configs are valid for first install.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveConfigPath(
|
|
195
|
+
fs: CliFs,
|
|
196
|
+
env: NodeJS.ProcessEnv,
|
|
197
|
+
basename: string = 'opencode',
|
|
198
|
+
): { path: string; exists: boolean } {
|
|
199
|
+
const dir = resolveConfigDir(env);
|
|
200
|
+
const jsonPath = join(dir, basename + '.json');
|
|
201
|
+
const jsoncPath = join(dir, basename + '.jsonc');
|
|
202
|
+
|
|
203
|
+
if (fs.existsSync(jsonPath)) {
|
|
204
|
+
return { path: jsonPath, exists: true };
|
|
205
|
+
}
|
|
206
|
+
if (fs.existsSync(jsoncPath)) {
|
|
207
|
+
return { path: jsoncPath, exists: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { path: jsonPath, exists: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// normalizePlugin
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Normalize a plugin value (undefined, null, string[], or legacy object)
|
|
219
|
+
* to a flat string[].
|
|
220
|
+
*/
|
|
221
|
+
export function normalizePlugin(raw: unknown): string[] {
|
|
222
|
+
if (raw == null) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(raw)) {
|
|
227
|
+
return raw.filter((v): v is string => typeof v === 'string');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof raw === 'object') {
|
|
231
|
+
const obj = raw as Record<string, unknown>;
|
|
232
|
+
return Object.keys(obj).filter(k => obj[k] != null && obj[k] !== false);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (typeof raw === 'string') {
|
|
236
|
+
return [raw];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
// matchesPlugin
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
function escapeRegex(s: string): string {
|
|
247
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns true if entry is the plugin named name (with optional @version).
|
|
252
|
+
*/
|
|
253
|
+
export function matchesPlugin(entry: string, name: string = PLUGIN_NAME): boolean {
|
|
254
|
+
if (!entry || typeof entry !== 'string') return false;
|
|
255
|
+
if (entry === name) return true;
|
|
256
|
+
const pattern = new RegExp('^' + escapeRegex(name) + '@');
|
|
257
|
+
return pattern.test(entry);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
// dedupePlugins
|
|
262
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Deduplicate plugin list: remove all entries matching PLUGIN_NAME,
|
|
266
|
+
* then re-append the freshest entry (last occurrence). Other plugins preserved.
|
|
267
|
+
*/
|
|
268
|
+
export function dedupePlugins(
|
|
269
|
+
plugins: string[],
|
|
270
|
+
name: string = PLUGIN_NAME,
|
|
271
|
+
): string[] {
|
|
272
|
+
const others: string[] = [];
|
|
273
|
+
let lastFresh: string | undefined;
|
|
274
|
+
|
|
275
|
+
for (const p of plugins) {
|
|
276
|
+
if (matchesPlugin(p, name)) {
|
|
277
|
+
lastFresh = p;
|
|
278
|
+
} else {
|
|
279
|
+
others.push(p);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const result = [...others];
|
|
284
|
+
if (lastFresh !== undefined) {
|
|
285
|
+
result.push(lastFresh);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
// buildSpecifier
|
|
292
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build an npm version specifier.
|
|
296
|
+
* '' | undefined -> '@latest'; explicit -> '@<version>'
|
|
297
|
+
*/
|
|
298
|
+
export function buildSpecifier(version?: string): string {
|
|
299
|
+
if (!version || version.trim() === '') {
|
|
300
|
+
return '@latest';
|
|
301
|
+
}
|
|
302
|
+
return '@' + version;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
306
|
+
// loadGlobalConfig
|
|
307
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
export interface GlobalConfig {
|
|
310
|
+
path: string;
|
|
311
|
+
exists: boolean;
|
|
312
|
+
data: Record<string, unknown>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Load and parse a global config file.
|
|
317
|
+
* Returns { path, exists, data }. data is {} when file absent or empty.
|
|
318
|
+
* Throws if the file exists but contains malformed JSON.
|
|
319
|
+
*/
|
|
320
|
+
export function loadGlobalConfig(
|
|
321
|
+
fs: CliFs,
|
|
322
|
+
env: NodeJS.ProcessEnv,
|
|
323
|
+
basename: string = 'opencode',
|
|
324
|
+
): GlobalConfig {
|
|
325
|
+
const resolved = resolveConfigPath(fs, env, basename);
|
|
326
|
+
|
|
327
|
+
if (!resolved.exists) {
|
|
328
|
+
return { path: resolved.path, exists: false, data: {} };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const raw = fs.readFileSync(resolved.path);
|
|
332
|
+
try {
|
|
333
|
+
const data = parseJsonc(raw);
|
|
334
|
+
return { path: resolved.path, exists: true, data };
|
|
335
|
+
} catch (err) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`config file at ${resolved.path} is malformed JSON\n` +
|
|
338
|
+
`Fix the JSON error, or delete the file and re-run.\n` +
|
|
339
|
+
` error: ${(err as Error).message}`,
|
|
340
|
+
);
|
|
341
|
+
}}
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
// Backup helpers
|
|
345
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
function timestamp(): string {
|
|
348
|
+
const now = new Date();
|
|
349
|
+
const y = now.getUTCFullYear();
|
|
350
|
+
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
351
|
+
const d = String(now.getUTCDate()).padStart(2, '0');
|
|
352
|
+
const hh = String(now.getUTCHours()).padStart(2, '0');
|
|
353
|
+
const mm = String(now.getUTCMinutes()).padStart(2, '0');
|
|
354
|
+
const ss = String(now.getUTCSeconds()).padStart(2, '0');
|
|
355
|
+
return '' + y + m + d + 'T' + hh + mm + ss;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Create a timestamped backup of path if it exists.
|
|
360
|
+
* Returns the backup path, or undefined if no backup was created.
|
|
361
|
+
*/
|
|
362
|
+
export function backupIfWritable(fs: CliFs, path: string): string | undefined {
|
|
363
|
+
if (!fs.existsSync(path)) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const content = fs.readFileSync(path);
|
|
369
|
+
const dir = dirname(path);
|
|
370
|
+
const segs = path.replace(/\\/g, '/').split('/');
|
|
371
|
+
const base = segs[segs.length - 1] ?? path;
|
|
372
|
+
const dot = base.lastIndexOf('.');
|
|
373
|
+
const name = dot >= 0 ? base.slice(0, dot) : base;
|
|
374
|
+
const backupPath = join(dir, name + '.bak.' + timestamp());
|
|
375
|
+
fs.writeFileSync(backupPath, content);
|
|
376
|
+
return backupPath;
|
|
377
|
+
} catch {
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Rotate backups: keep at most `limit` backups matching `basename.bak.*`,
|
|
384
|
+
* deleting the oldest (by timestamp sort) when over limit.
|
|
385
|
+
*/
|
|
386
|
+
export function rotateBackups(
|
|
387
|
+
fs: CliFs,
|
|
388
|
+
dir: string,
|
|
389
|
+
basename: string,
|
|
390
|
+
limit: number = BACKUP_LIMIT,
|
|
391
|
+
): void {
|
|
392
|
+
let entries: string[] = [];
|
|
393
|
+
try {
|
|
394
|
+
entries = fs.readdirSync(dir);
|
|
395
|
+
} catch {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const prefix = basename + '.bak.';
|
|
400
|
+
const backups = entries
|
|
401
|
+
.filter(e => e.startsWith(prefix))
|
|
402
|
+
.map(e => ({ name: e, path: join(dir, e) }))
|
|
403
|
+
.filter(({ path }) => fs.existsSync(path))
|
|
404
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
405
|
+
|
|
406
|
+
if (backups.length <= limit) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const toDelete = backups.slice(0, backups.length - limit);
|
|
411
|
+
for (const { path } of toDelete) {
|
|
412
|
+
try {
|
|
413
|
+
fs.unlinkSync(path);
|
|
414
|
+
} catch {
|
|
415
|
+
// best-effort
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
421
|
+
// writeAtomically
|
|
422
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Write content to path atomically:
|
|
426
|
+
* 1. Write to a sibling .tmp.<name> file
|
|
427
|
+
* 2. renameSync over the target
|
|
428
|
+
* 3. Clean up temp file on error
|
|
429
|
+
*/
|
|
430
|
+
export function writeAtomically(fs: CliFs, path: string, content: string): void {
|
|
431
|
+
const dir = dirname(path);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
435
|
+
} catch {
|
|
436
|
+
// may already exist
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const segs = path.replace(/\\/g, '/').split('/');
|
|
440
|
+
const base = segs[segs.length - 1] ?? path;
|
|
441
|
+
const dot = base.lastIndexOf('.');
|
|
442
|
+
const name = dot >= 0 ? base.slice(0, dot) : base;
|
|
443
|
+
const ext = dot >= 0 ? base.slice(dot) : '';
|
|
444
|
+
const tmpPath = join(dir, '.tmp.' + name + ext);
|
|
445
|
+
|
|
446
|
+
let tempCreated = false;
|
|
447
|
+
try {
|
|
448
|
+
fs.writeFileSync(tmpPath, content);
|
|
449
|
+
tempCreated = true;
|
|
450
|
+
fs.renameSync(tmpPath, path);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (tempCreated) {
|
|
453
|
+
try {
|
|
454
|
+
fs.unlinkSync(tmpPath);
|
|
455
|
+
} catch {
|
|
456
|
+
// ignore cleanup failure
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// src/cli/install.ts — `omd install` command implementation.
|
|
3
|
+
//
|
|
4
|
+
// Dual-config install loop: iterates over ["opencode", "tui"], loads each
|
|
5
|
+
// config, normalizes + deduplicates the plugin list, appends the fresh
|
|
6
|
+
// specifier, and writes atomically with a timestamped backup. When the version
|
|
7
|
+
// is omitted or "latest", the latest published npm version is resolved so the
|
|
8
|
+
// written specifier matches the concrete version (important for accurate
|
|
9
|
+
// update checks) and any stale local cache can be purged.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import { dirname, join } from 'path';
|
|
13
|
+
import {
|
|
14
|
+
PLUGIN_NAME,
|
|
15
|
+
loadGlobalConfig,
|
|
16
|
+
matchesPlugin,
|
|
17
|
+
normalizePlugin,
|
|
18
|
+
backupIfWritable,
|
|
19
|
+
rotateBackups,
|
|
20
|
+
writeAtomically,
|
|
21
|
+
type CliFs,
|
|
22
|
+
} from './config.js';
|
|
23
|
+
import { fetchLatestVersion } from './registry.js';
|
|
24
|
+
import { purgeDirectory, resolveCachePath } from './update.js';
|
|
25
|
+
|
|
26
|
+
export const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
|
|
27
|
+
|
|
28
|
+
export interface InstallOptions {
|
|
29
|
+
version?: string;
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
yes?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Optional injection point for tests: pretend the npm registry returned this
|
|
34
|
+
* version when the requested version is "latest" or unset. When omitted,
|
|
35
|
+
* `fetchLatestVersion()` is called against the real npm registry.
|
|
36
|
+
*/
|
|
37
|
+
latestVersion?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InstallResultPerFile {
|
|
41
|
+
path: string;
|
|
42
|
+
status: 'wrote' | 'skipped' | 'error';
|
|
43
|
+
backup: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InstallResult {
|
|
47
|
+
status: 'wrote' | 'skipped';
|
|
48
|
+
results: InstallResultPerFile[];
|
|
49
|
+
/** True when a stale local cache directory was purged after writing. */
|
|
50
|
+
purged?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the concrete version to write.
|
|
55
|
+
*
|
|
56
|
+
* - If the user supplied an explicit version (not "latest" and not empty), use
|
|
57
|
+
* it unchanged.
|
|
58
|
+
* - Otherwise, ask the npm registry for the latest published version.
|
|
59
|
+
* - If the registry is unreachable, fall back to the literal "@latest" tag so
|
|
60
|
+
* the install command never hard-fails due to network issues. This preserves
|
|
61
|
+
* the previous behavior while still allowing the cache check to compare when
|
|
62
|
+
* a concrete version is known.
|
|
63
|
+
*/
|
|
64
|
+
async function resolveVersion(opts: InstallOptions): Promise<string> {
|
|
65
|
+
const raw = opts.version?.trim() ?? '';
|
|
66
|
+
const wantsLatest = raw === '' || raw === 'latest';
|
|
67
|
+
|
|
68
|
+
if (!wantsLatest) {
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Tests can short-circuit the network call via latestVersion.
|
|
73
|
+
if (opts.latestVersion !== undefined) {
|
|
74
|
+
return opts.latestVersion;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const latest = await fetchLatestVersion();
|
|
78
|
+
return latest ?? 'latest';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the version field from a cached package.json, if present.
|
|
83
|
+
* Returns null when the file is missing or unreadable.
|
|
84
|
+
*/
|
|
85
|
+
function readCacheVersion(fs: CliFs, cachePath: string): string | null {
|
|
86
|
+
const pkgPath = join(cachePath, 'package.json');
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.existsSync(pkgPath)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath)) as { version?: unknown };
|
|
92
|
+
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
93
|
+
} catch {
|
|
94
|
+
// A broken cache is treated as "version unknown" so it gets purged.
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Purge the local cache directory when it is stale relative to the version we
|
|
101
|
+
* just installed. If the cache version cannot be determined, we err on the
|
|
102
|
+
* side of purging so the next npx invocation fetches a fresh copy.
|
|
103
|
+
*/
|
|
104
|
+
function purgeCacheIfStale(
|
|
105
|
+
fs: CliFs,
|
|
106
|
+
env: NodeJS.ProcessEnv,
|
|
107
|
+
resolvedVersion: string,
|
|
108
|
+
): boolean {
|
|
109
|
+
// The literal "latest" tag is not a comparable version; we only purge when
|
|
110
|
+
// we have a concrete resolved version to compare against.
|
|
111
|
+
if (resolvedVersion === 'latest') {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cachePath = resolveCachePath(env);
|
|
116
|
+
if (!fs.existsSync(cachePath)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cacheVersion = readCacheVersion(fs, cachePath);
|
|
121
|
+
if (cacheVersion === null || cacheVersion !== resolvedVersion) {
|
|
122
|
+
purgeDirectory(fs, cachePath);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Install opencode-rules-md into both opencode.json and tui.json configs.
|
|
131
|
+
*
|
|
132
|
+
* Options:
|
|
133
|
+
* version — npm version specifier (default: latest from registry)
|
|
134
|
+
* dryRun — run full pipeline without writing to disk
|
|
135
|
+
* yes — accepted for future prompts, no-op here
|
|
136
|
+
* latestVersion — test hook for the resolved latest version
|
|
137
|
+
*/
|
|
138
|
+
export const runInstall = async (
|
|
139
|
+
opts: InstallOptions = {},
|
|
140
|
+
fs: CliFs,
|
|
141
|
+
env: NodeJS.ProcessEnv,
|
|
142
|
+
): Promise<InstallResult> => {
|
|
143
|
+
const resolvedVersion = await resolveVersion(opts);
|
|
144
|
+
const freshEntry = `${PLUGIN_NAME}@${resolvedVersion}`;
|
|
145
|
+
const results: InstallResultPerFile[] = [];
|
|
146
|
+
let anyProcessed = false;
|
|
147
|
+
|
|
148
|
+
for (const basename of CONFIG_BASENAMES) {
|
|
149
|
+
const loaded = loadGlobalConfig(fs, env, basename);
|
|
150
|
+
const plugins = normalizePlugin(loaded.data['plugins']);
|
|
151
|
+
|
|
152
|
+
// Find existing entry for this plugin
|
|
153
|
+
const existingEntry = plugins.find(p => matchesPlugin(p));
|
|
154
|
+
|
|
155
|
+
// No-op if the same specifier is already installed
|
|
156
|
+
if (existingEntry === freshEntry) {
|
|
157
|
+
results.push({ path: loaded.path, status: 'skipped', backup: null });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
anyProcessed = true;
|
|
162
|
+
|
|
163
|
+
// Build the new plugin list: remove all matching entries, append fresh specifier.
|
|
164
|
+
// Existing non-matching plugins are preserved in their original order.
|
|
165
|
+
const withoutStale = plugins.filter(p => !matchesPlugin(p));
|
|
166
|
+
const newPlugins = [...withoutStale, freshEntry];
|
|
167
|
+
|
|
168
|
+
const newData = { ...loaded.data, plugins: newPlugins };
|
|
169
|
+
const newContent = JSON.stringify(newData, null, 2) + '\n';
|
|
170
|
+
|
|
171
|
+
if (opts.dryRun) {
|
|
172
|
+
results.push({ path: loaded.path, status: 'wrote', backup: null });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Backup the existing file if it exists
|
|
177
|
+
let backup: string | undefined;
|
|
178
|
+
if (loaded.exists) {
|
|
179
|
+
backup = backupIfWritable(fs, loaded.path);
|
|
180
|
+
if (backup !== undefined) {
|
|
181
|
+
const dir = dirname(loaded.path);
|
|
182
|
+
const segs = loaded.path.replace(/\\/g, '/').split('/');
|
|
183
|
+
const base = segs[segs.length - 1] ?? loaded.path;
|
|
184
|
+
const dot = base.lastIndexOf('.');
|
|
185
|
+
const name = dot >= 0 ? base.slice(0, dot) : base;
|
|
186
|
+
rotateBackups(fs, dir, name, 3);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
writeAtomically(fs, loaded.path, newContent);
|
|
191
|
+
results.push({
|
|
192
|
+
path: loaded.path,
|
|
193
|
+
status: 'wrote',
|
|
194
|
+
backup: backup ?? null,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// After writing the configs, purge a stale local cache so the next run of the
|
|
199
|
+
// plugin starts from the version we just registered. In dry-run mode we
|
|
200
|
+
// never touch the filesystem.
|
|
201
|
+
let purged = false;
|
|
202
|
+
if (!opts.dryRun) {
|
|
203
|
+
purged = purgeCacheIfStale(fs, env, resolvedVersion);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
status: anyProcessed ? 'wrote' : 'skipped',
|
|
208
|
+
results,
|
|
209
|
+
purged,
|
|
210
|
+
};
|
|
211
|
+
};
|