opencode-rules-md 0.6.5 → 0.7.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.
package/dist/cli.mjs CHANGED
@@ -2,67 +2,104 @@
2
2
 
3
3
  // src/cli/main.ts
4
4
  import { parseArgs } from "node:util";
5
+ import { pathToFileURL } from "node:url";
6
+ import { realpathSync } from "node:fs";
5
7
 
6
8
  // src/cli/config.ts
7
9
  import * as os from "node:os";
8
10
  import * as path from "node:path";
11
+ var SERVER_CONFIG_FILENAME = "opencode.json";
12
+ var TUI_CONFIG_FILENAME = "tui.json";
9
13
  function stripJsoncComments(content) {
10
14
  let result = "";
11
15
  let i = 0;
12
- while (i < content.length) {
16
+ let inString = false;
17
+ let escaped = false;
18
+ const len = content.length;
19
+ while (i < len) {
13
20
  const char = content[i];
21
+ if (inString) {
22
+ result += char;
23
+ if (escaped) {
24
+ escaped = false;
25
+ } else if (char === "\\") {
26
+ escaped = true;
27
+ } else if (char === '"') {
28
+ inString = false;
29
+ }
30
+ i++;
31
+ continue;
32
+ }
14
33
  if (char === '"') {
34
+ inString = true;
15
35
  result += char;
16
36
  i++;
17
- while (i < content.length) {
18
- const c = content[i];
19
- if (c === "\\" && i + 1 < content.length) {
20
- result += c + content[i + 1];
21
- i += 2;
22
- continue;
23
- }
24
- if (c === '"') {
25
- result += c;
26
- i++;
27
- break;
28
- }
29
- result += c;
30
- i++;
31
- }
32
37
  continue;
33
38
  }
34
39
  if (char === "/" && content[i + 1] === "/") {
35
- while (i < content.length && content[i] !== `
36
- `) {
40
+ i += 2;
41
+ while (i < len && content[i] !== `
42
+ `)
37
43
  i++;
38
- }
39
44
  continue;
40
45
  }
41
46
  if (char === "/" && content[i + 1] === "*") {
42
47
  i += 2;
43
- while (i + 1 < content.length && !(content[i] === "*" && content[i + 1] === "/")) {
48
+ while (i < len && !(content[i] === "*" && content[i + 1] === "/"))
44
49
  i++;
45
- }
46
- if (i + 1 < content.length) {
50
+ if (i < len)
47
51
  i += 2;
52
+ continue;
53
+ }
54
+ result += char;
55
+ i++;
56
+ }
57
+ let out = "";
58
+ let inStr = false;
59
+ let esc = false;
60
+ let j = 0;
61
+ const outLen = result.length;
62
+ while (j < outLen) {
63
+ const ch = result[j];
64
+ if (inStr) {
65
+ out += ch;
66
+ if (esc) {
67
+ esc = false;
68
+ } else if (ch === "\\") {
69
+ esc = true;
70
+ } else if (ch === '"') {
71
+ inStr = false;
48
72
  }
73
+ j++;
49
74
  continue;
50
75
  }
51
- if (char === "," && (content[i + 1] === "}" || content[i + 1] === "]")) {
52
- i++;
76
+ if (ch === '"') {
77
+ inStr = true;
78
+ out += ch;
79
+ j++;
53
80
  continue;
54
81
  }
55
- result += char;
56
- i++;
82
+ if (ch === ",") {
83
+ let k = j + 1;
84
+ while (k < outLen && /\s/.test(result[k]))
85
+ k++;
86
+ if (k < outLen && /[}\]]/.test(result[k])) {
87
+ j = k;
88
+ continue;
89
+ }
90
+ }
91
+ out += ch;
92
+ j++;
57
93
  }
58
- return result;
94
+ return out;
59
95
  }
60
96
  function resolveGlobalConfigPath(fs, opts = {}) {
61
97
  const dir = resolveConfigDir();
62
98
  if (opts.ensureDir && !fs.existsSync(dir)) {
63
99
  fs.mkdirSync(dir, { recursive: true });
64
100
  }
65
- return path.join(dir, "opencode.json");
101
+ const filename = opts.filename ?? SERVER_CONFIG_FILENAME;
102
+ return path.join(dir, filename);
66
103
  }
67
104
  function resolveConfigDir() {
68
105
  if (process.env.OPENCODE_CONFIG_DIR) {
@@ -81,8 +118,8 @@ function resolveConfigDir() {
81
118
  }
82
119
  throw new Error("Cannot resolve config dir: none of OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, HOME, or APPDATA is set");
83
120
  }
84
- function loadGlobalConfig(fs) {
85
- const configPath = resolveGlobalConfigPath(fs);
121
+ function loadGlobalConfig(fs, opts = {}) {
122
+ const configPath = resolveGlobalConfigPath(fs, opts.filename ? { filename: opts.filename } : {});
86
123
  if (!fs.existsSync(configPath)) {
87
124
  return { path: configPath, existed: false, config: {} };
88
125
  }
@@ -129,15 +166,14 @@ function addPlugin(config, specifier) {
129
166
  config["plugin"] = removed;
130
167
  }
131
168
  function rotateBackups(fs, configPath, _pluginName) {
169
+ if (!fs.existsSync(configPath)) {
170
+ return null;
171
+ }
132
172
  const dir = path.dirname(configPath);
133
173
  const base = path.basename(configPath);
134
174
  const timestamp = Date.now();
135
175
  const newBak = path.join(dir, `${base}.bak.${timestamp}`);
136
- if (fs.existsSync(configPath)) {
137
- fs.copyFileSync(configPath, newBak);
138
- } else {
139
- fs.writeFileSync(newBak, "", "utf-8");
140
- }
176
+ fs.copyFileSync(configPath, newBak);
141
177
  const entries = fs.readdirSync(dir);
142
178
  const bakFiles = entries.filter((e) => e.startsWith(base + ".bak.")).sort();
143
179
  while (bakFiles.length > 3) {
@@ -149,7 +185,7 @@ function rotateBackups(fs, configPath, _pluginName) {
149
185
  function writeAtomically(fs, configPath, content) {
150
186
  const dir = path.dirname(configPath);
151
187
  const base = path.basename(configPath);
152
- const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}`);
188
+ const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}-${process.pid}`);
153
189
  fs.writeFileSync(tmpPath, content, "utf-8");
154
190
  fs.renameSync(tmpPath, configPath);
155
191
  }
@@ -198,71 +234,104 @@ var realFs = {
198
234
 
199
235
  // src/cli/install.ts
200
236
  function runInstall(opts, fs2 = realFs) {
201
- const loadResult = loadGlobalConfig(fs2);
237
+ const specifier = buildSpecifier(opts.version);
238
+ const server = updateConfig(specifier, opts.dryRun ?? false, fs2);
239
+ const tui = updateConfig(specifier, opts.dryRun ?? false, fs2, {
240
+ filename: TUI_CONFIG_FILENAME
241
+ });
242
+ const aggregate = server.status === "wrote" || tui.status === "wrote" ? "wrote" : server.status === "planned" || tui.status === "planned" ? "planned" : "noop";
243
+ printSummary(aggregate, specifier, server, tui);
244
+ return { status: aggregate, specifier, server, tui };
245
+ }
246
+ function updateConfig(specifier, dryRun, fs2, opts = {}) {
247
+ const loadResult = loadGlobalConfig(fs2, opts.filename ? { filename: opts.filename } : {});
202
248
  if (loadResult.parseError) {
203
- return {
204
- status: "error",
205
- path: loadResult.path,
206
- specifier: buildSpecifier(opts.version),
207
- parseError: loadResult.parseError
208
- };
249
+ const filename = opts.filename ?? "opencode.json";
250
+ const err = new Error(`opencode-rules-md: ${filename} is malformed — aborting to avoid data loss.
251
+ ` + ` path: ${loadResult.path}
252
+ ` + ` error: ${loadResult.parseError.message}`);
253
+ err.configPath = loadResult.path;
254
+ throw err;
209
255
  }
210
256
  const configPath = loadResult.path;
211
257
  const config = loadResult.config;
212
258
  config["plugin"] = normalizePlugin(config);
213
- const specifier = buildSpecifier(opts.version);
214
259
  const existing = config["plugin"] ?? [];
215
260
  if (existing.includes(specifier)) {
216
261
  return { status: "noop", path: configPath, specifier };
217
262
  }
218
263
  removePlugin(config, "opencode-rules-md");
219
264
  addPlugin(config, specifier);
220
- if (opts.dryRun) {
265
+ if (dryRun) {
221
266
  const planned = JSON.stringify(config, null, 2);
222
- console.log(`Planned config:
223
- ` + planned);
267
+ console.log(`Planned ${configPath}:
268
+ ${planned}`);
224
269
  return { status: "planned", path: configPath, specifier };
225
270
  }
226
271
  const backup = rotateBackups(fs2, configPath, "opencode-rules-md");
227
272
  const serialized = JSON.stringify(config, null, 2);
228
273
  writeAtomically(fs2, configPath, serialized);
229
- console.log(`Installed ${specifier} to ${configPath}`);
230
- if (backup) {
231
- console.log(`Backup written to ${backup}`);
232
- }
233
- return { status: "wrote", path: configPath, specifier, backup };
274
+ const result = { status: "wrote", path: configPath, specifier };
275
+ if (backup)
276
+ result.backup = backup;
277
+ return result;
234
278
  }
235
279
  function buildSpecifier(version) {
236
280
  return version ? `opencode-rules-md@${version}` : "opencode-rules-md";
237
281
  }
282
+ function printSummary(status, specifier, server, tui) {
283
+ if (status === "noop") {
284
+ console.log(`Already installed (${specifier})`);
285
+ console.log(` server config: ${server.path}`);
286
+ console.log(` tui config: ${tui.path}`);
287
+ return;
288
+ }
289
+ if (status === "planned") {
290
+ console.log("Dry run complete — no files written.");
291
+ return;
292
+ }
293
+ console.log(`Installed ${specifier}`);
294
+ console.log(` server config: ${server.path}`);
295
+ if (server.backup)
296
+ console.log(` backup: ${server.backup}`);
297
+ console.log(` tui config: ${tui.path}`);
298
+ if (tui.backup)
299
+ console.log(` backup: ${tui.backup}`);
300
+ }
238
301
 
239
302
  // src/cli/status.ts
240
303
  import * as fs2 from "node:fs";
241
304
  import * as path2 from "node:path";
242
- function runStatus(fs3 = realFs) {
243
- const loadResult = loadGlobalConfig(fs3);
244
- const configPath = loadResult.path;
245
- if (loadResult.parseError) {
246
- return {
247
- installed: false,
248
- path: configPath,
249
- version: getVersion(),
250
- parseError: loadResult.parseError
251
- };
252
- }
253
- const config = loadResult.config;
254
- const pluginList = Array.isArray(config["plugin"]) ? config["plugin"] : [];
255
- const specifier = pluginList.find((p) => p.startsWith("opencode-rules-md"));
305
+ function runStatus(cliFs = realFs) {
306
+ const serverLoad = loadGlobalConfig(cliFs);
307
+ const tuiLoad = loadGlobalConfig(cliFs, { filename: TUI_CONFIG_FILENAME });
308
+ const serverSpecifier = findSpecifier(serverLoad);
309
+ const tuiSpecifier = findSpecifier(tuiLoad);
310
+ const installed = serverSpecifier !== undefined && tuiSpecifier !== undefined;
256
311
  const result = {
257
- installed: specifier !== undefined,
258
- path: configPath,
312
+ installed,
313
+ serverPath: serverLoad.path,
314
+ tuiPath: tuiLoad.path,
315
+ serverExisted: serverLoad.existed,
316
+ tuiExisted: tuiLoad.existed,
259
317
  version: getVersion()
260
318
  };
261
- if (specifier) {
262
- result.specifier = specifier;
263
- }
319
+ if (serverSpecifier)
320
+ result.serverSpecifier = serverSpecifier;
321
+ if (tuiSpecifier)
322
+ result.tuiSpecifier = tuiSpecifier;
323
+ const parseError = serverLoad.parseError ?? tuiLoad.parseError;
324
+ if (parseError)
325
+ result.parseError = parseError;
264
326
  return result;
265
327
  }
328
+ function findSpecifier(load) {
329
+ if (load.parseError)
330
+ return;
331
+ const config = load.config;
332
+ const pluginList = Array.isArray(config["plugin"]) ? config["plugin"] : [];
333
+ return pluginList.find((p) => p.startsWith("opencode-rules-md"));
334
+ }
266
335
  function getVersion() {
267
336
  try {
268
337
  const pkgPath = path2.resolve(process.cwd(), "package.json");
@@ -280,7 +349,7 @@ var HELP_TEXT = `opencode-rules-md CLI
280
349
  Usage: opencode-rules-md <command> [options]
281
350
 
282
351
  Commands:
283
- install Add opencode-rules-md to the global opencode config
352
+ install Add opencode-rules-md to the global opencode + tui configs
284
353
  status Report whether opencode-rules-md is installed
285
354
 
286
355
  Options:
@@ -290,8 +359,7 @@ Options:
290
359
  --yes Skip confirmation prompts (future use)
291
360
  -h, --help Show this help text
292
361
  `.trim();
293
- var USAGE_ERROR_TEXT = `Error: unknown command. Run 'opencode-rules-md --help' for usage.
294
- `.trim();
362
+ var USAGE_ERROR_TEXT = `Error: unknown command. Run 'opencode-rules-md --help' for usage.`.trim();
295
363
  function parseCliArgs(argv) {
296
364
  const { positionals, values } = parseArgs({
297
365
  args: argv,
@@ -339,16 +407,14 @@ async function runMain(argv, fs3 = realFs) {
339
407
  installOpts.version = options.version;
340
408
  if (options.dryRun)
341
409
  installOpts.dryRun = true;
342
- const installResult = runInstall(installOpts, fs3);
343
- if (installResult.status === "error") {
344
- return 1;
345
- }
410
+ runInstall(installOpts, fs3);
346
411
  return 0;
347
412
  }
348
413
  case "status": {
349
414
  const statusResult = runStatus(fs3);
350
415
  if (statusResult.installed) {
351
- console.log(`opencode-rules-md is installed (${statusResult.specifier})`);
416
+ const spec = statusResult.serverSpecifier ?? statusResult.tuiSpecifier;
417
+ console.log(`opencode-rules-md is installed (${spec})`);
352
418
  } else {
353
419
  console.log("opencode-rules-md is not installed");
354
420
  }
@@ -361,13 +427,27 @@ async function runMain(argv, fs3 = realFs) {
361
427
  }
362
428
  }
363
429
  } catch (err) {
364
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ console.error(`Error: ${message}`);
365
432
  return 1;
366
433
  }
367
434
  }
368
- if (import.meta.url === `file://${process.argv[1]}`) {
369
- const exitCode = await runMain(process.argv.slice(2));
370
- process.exit(exitCode);
435
+ function isInvokedAsMain() {
436
+ if (!process.argv[1])
437
+ return false;
438
+ try {
439
+ const realArgv = pathToFileURL(realpathSync(process.argv[1])).href;
440
+ return import.meta.url === realArgv;
441
+ } catch {
442
+ try {
443
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+ }
449
+ if (isInvokedAsMain()) {
450
+ runMain(process.argv.slice(2)).then((code) => process.exit(code));
371
451
  }
372
452
  export {
373
453
  runMain
@@ -6,6 +6,12 @@
6
6
  * an in-memory implementation without touching real disk.
7
7
  */
8
8
  import type { CliFs } from './real-fs.js';
9
+ /** npm package name for this plugin. */
10
+ export declare const PLUGIN_NAME = "opencode-rules-md";
11
+ /** Filename for the OpenCode server config. */
12
+ export declare const SERVER_CONFIG_FILENAME = "opencode.json";
13
+ /** Filename for the OpenCode TUI config. */
14
+ export declare const TUI_CONFIG_FILENAME = "tui.json";
9
15
  export interface LoadResult {
10
16
  path: string;
11
17
  existed: boolean;
@@ -15,6 +21,9 @@ export interface LoadResult {
15
21
  /**
16
22
  * Strip JSONC comments and trailing commas while preserving
17
23
  * comment-like sequences inside string literals.
24
+ *
25
+ * Pass 1: strip `//` and `/* * /` comments (string-aware).
26
+ * Pass 2: strip trailing commas before `}` or `]`, skipping whitespace.
18
27
  */
19
28
  export declare function stripJsoncComments(content: string): string;
20
29
  /**
@@ -26,13 +35,16 @@ export declare function stripJsoncComments(content: string): string;
26
35
  */
27
36
  export declare function resolveGlobalConfigPath(fs: CliFs, opts?: {
28
37
  ensureDir?: boolean;
38
+ filename?: string;
29
39
  }): string;
30
40
  /**
31
- * Load and parse the global opencode config.
41
+ * Load and parse a global OpenCode config file.
32
42
  * Treats a missing file as an empty config object (not an error).
33
43
  * Surfaces parse errors so callers can exit non-zero without corrupting data.
34
44
  */
35
- export declare function loadGlobalConfig(fs: CliFs): LoadResult;
45
+ export declare function loadGlobalConfig(fs: CliFs, opts?: {
46
+ filename?: string;
47
+ }): LoadResult;
36
48
  /**
37
49
  * Return the plugin array with opencode-rules-md* entries deduplicated by
38
50
  * name prefix, keeping the last occurrence of each unique prefix.
@@ -51,9 +63,9 @@ export declare function addPlugin(config: Record<string, unknown>, specifier: st
51
63
  /**
52
64
  * Write a timestamped backup of the given config path and rotate so at most
53
65
  * 3 backups are retained (oldest deleted first).
54
- * Returns the path of the newly created backup.
66
+ * Returns the path of the newly created backup, or null if no backup was created.
55
67
  */
56
- export declare function rotateBackups(fs: CliFs, configPath: string, _pluginName: string): string;
68
+ export declare function rotateBackups(fs: CliFs, configPath: string, _pluginName: string): string | null;
57
69
  /**
58
70
  * Write content to a temporary sibling file then rename it into place.
59
71
  * The final path is never in a partially-written state.
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/cli/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAM1C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,KAAK,CAAC;CACpB;AAMD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAgE1D;AAMD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,KAAK,EACT,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,MAAM,CAMR;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,KAAK,GAAG,UAAU,CAoBtD;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,MAAM,EAAE,CAcV;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,GACb,IAAI,CAMN;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,IAAI,CAUN;AAMD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAyBxF;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAOpF"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/cli/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAM1C,wCAAwC;AACxC,eAAO,MAAM,WAAW,sBAAsB,CAAC;AAE/C,+CAA+C;AAC/C,eAAO,MAAM,sBAAsB,kBAAkB,CAAC;AAEtD,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAM9C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,KAAK,CAAC;CACpB;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAgG1D;AAMD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,KAAK,EACT,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GACpD,MAAM,CAOR;AA+BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,UAAU,CAuBxF;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,MAAM,EAAE,CAcV;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,GACb,IAAI,CAMN;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,IAAI,CAUN;AAMD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyB/F;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAOpF"}
@@ -8,68 +8,115 @@
8
8
  import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
10
  // ---------------------------------------------------------------------------
11
- // JSONC comment stripper
11
+ // Public constants
12
+ // ---------------------------------------------------------------------------
13
+ /** npm package name for this plugin. */
14
+ export const PLUGIN_NAME = 'opencode-rules-md';
15
+ /** Filename for the OpenCode server config. */
16
+ export const SERVER_CONFIG_FILENAME = 'opencode.json';
17
+ /** Filename for the OpenCode TUI config. */
18
+ export const TUI_CONFIG_FILENAME = 'tui.json';
19
+ // ---------------------------------------------------------------------------
20
+ // JSONC comment stripper — two-pass
12
21
  // ---------------------------------------------------------------------------
13
22
  /**
14
23
  * Strip JSONC comments and trailing commas while preserving
15
24
  * comment-like sequences inside string literals.
25
+ *
26
+ * Pass 1: strip `//` and `/* * /` comments (string-aware).
27
+ * Pass 2: strip trailing commas before `}` or `]`, skipping whitespace.
16
28
  */
17
29
  export function stripJsoncComments(content) {
30
+ // Pass 1 — strip comments while preserving string contents
18
31
  let result = '';
19
32
  let i = 0;
20
- while (i < content.length) {
33
+ let inString = false;
34
+ let escaped = false;
35
+ const len = content.length;
36
+ while (i < len) {
21
37
  const char = content[i];
22
- // Start of string (double-quoted only — JSON only supports double quotes)?
38
+ if (inString) {
39
+ result += char;
40
+ if (escaped) {
41
+ escaped = false;
42
+ }
43
+ else if (char === '\\') {
44
+ escaped = true;
45
+ }
46
+ else if (char === '"') {
47
+ inString = false;
48
+ }
49
+ i++;
50
+ continue;
51
+ }
23
52
  if (char === '"') {
53
+ inString = true;
24
54
  result += char;
25
55
  i++;
26
- while (i < content.length) {
27
- const c = content[i];
28
- if (c === '\\' && i + 1 < content.length) {
29
- // Escape sequence — copy both chars verbatim
30
- result += c + content[i + 1];
31
- i += 2;
32
- continue;
33
- }
34
- if (c === '"') {
35
- result += c;
36
- i++;
37
- break;
38
- }
39
- result += c;
40
- i++;
41
- }
42
56
  continue;
43
57
  }
44
- // Line comment?
58
+ // Line comment: // until newline
45
59
  if (char === '/' && content[i + 1] === '/') {
46
- while (i < content.length && content[i] !== '\n') {
60
+ i += 2;
61
+ while (i < len && content[i] !== '\n')
47
62
  i++;
48
- }
49
63
  continue;
50
64
  }
51
- // Block comment?
65
+ // Block comment: /* until */
52
66
  if (char === '/' && content[i + 1] === '*') {
53
67
  i += 2;
54
- while (i + 1 < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
68
+ while (i < len && !(content[i] === '*' && content[i + 1] === '/'))
55
69
  i++;
70
+ if (i < len)
71
+ i += 2; // skip past */
72
+ continue;
73
+ }
74
+ result += char;
75
+ i++;
76
+ }
77
+ // Pass 2 — strip trailing commas (with whitespace skip) before } or ]
78
+ let out = '';
79
+ let inStr = false;
80
+ let esc = false;
81
+ let j = 0;
82
+ const outLen = result.length;
83
+ while (j < outLen) {
84
+ const ch = result[j];
85
+ if (inStr) {
86
+ out += ch;
87
+ if (esc) {
88
+ esc = false;
56
89
  }
57
- // Only skip past */ if we actually found it
58
- if (i + 1 < content.length) {
59
- i += 2; // skip */
90
+ else if (ch === '\\') {
91
+ esc = true;
60
92
  }
93
+ else if (ch === '"') {
94
+ inStr = false;
95
+ }
96
+ j++;
61
97
  continue;
62
98
  }
63
- // Trailing comma before } or ]
64
- if (char === ',' &&
65
- (content[i + 1] === '}' || content[i + 1] === ']')) {
66
- i++;
99
+ if (ch === '"') {
100
+ inStr = true;
101
+ out += ch;
102
+ j++;
67
103
  continue;
68
104
  }
69
- result += char;
70
- i++;
105
+ if (ch === ',') {
106
+ // Skip whitespace before checking for } or ]
107
+ let k = j + 1;
108
+ while (k < outLen && /\s/.test(result[k]))
109
+ k++;
110
+ if (k < outLen && /[}\]]/.test(result[k])) {
111
+ // Drop the comma; jump to the bracket
112
+ j = k;
113
+ continue;
114
+ }
115
+ }
116
+ out += ch;
117
+ j++;
71
118
  }
72
- return result;
119
+ return out;
73
120
  }
74
121
  // ---------------------------------------------------------------------------
75
122
  // Config path resolution
@@ -86,7 +133,8 @@ export function resolveGlobalConfigPath(fs, opts = {}) {
86
133
  if (opts.ensureDir && !fs.existsSync(dir)) {
87
134
  fs.mkdirSync(dir, { recursive: true });
88
135
  }
89
- return path.join(dir, 'opencode.json');
136
+ const filename = opts.filename ?? SERVER_CONFIG_FILENAME;
137
+ return path.join(dir, filename);
90
138
  }
91
139
  function resolveConfigDir() {
92
140
  if (process.env.OPENCODE_CONFIG_DIR) {
@@ -110,12 +158,12 @@ function resolveConfigDir() {
110
158
  // JSONC-safe config load
111
159
  // ---------------------------------------------------------------------------
112
160
  /**
113
- * Load and parse the global opencode config.
161
+ * Load and parse a global OpenCode config file.
114
162
  * Treats a missing file as an empty config object (not an error).
115
163
  * Surfaces parse errors so callers can exit non-zero without corrupting data.
116
164
  */
117
- export function loadGlobalConfig(fs) {
118
- const configPath = resolveGlobalConfigPath(fs);
165
+ export function loadGlobalConfig(fs, opts = {}) {
166
+ const configPath = resolveGlobalConfigPath(fs, opts.filename ? { filename: opts.filename } : {});
119
167
  if (!fs.existsSync(configPath)) {
120
168
  return { path: configPath, existed: false, config: {} };
121
169
  }
@@ -186,19 +234,17 @@ export function addPlugin(config, specifier) {
186
234
  /**
187
235
  * Write a timestamped backup of the given config path and rotate so at most
188
236
  * 3 backups are retained (oldest deleted first).
189
- * Returns the path of the newly created backup.
237
+ * Returns the path of the newly created backup, or null if no backup was created.
190
238
  */
191
239
  export function rotateBackups(fs, configPath, _pluginName) {
240
+ if (!fs.existsSync(configPath)) {
241
+ return null;
242
+ }
192
243
  const dir = path.dirname(configPath);
193
244
  const base = path.basename(configPath);
194
245
  const timestamp = Date.now();
195
246
  const newBak = path.join(dir, `${base}.bak.${timestamp}`);
196
- if (fs.existsSync(configPath)) {
197
- fs.copyFileSync(configPath, newBak);
198
- }
199
- else {
200
- fs.writeFileSync(newBak, '', 'utf-8');
201
- }
247
+ fs.copyFileSync(configPath, newBak);
202
248
  // Collect existing backups sorted by timestamp (lexical sort on numeric suffix)
203
249
  const entries = fs.readdirSync(dir);
204
250
  const bakFiles = entries
@@ -221,7 +267,7 @@ export function rotateBackups(fs, configPath, _pluginName) {
221
267
  export function writeAtomically(fs, configPath, content) {
222
268
  const dir = path.dirname(configPath);
223
269
  const base = path.basename(configPath);
224
- const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}`);
270
+ const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}-${process.pid}`);
225
271
  fs.writeFileSync(tmpPath, content, 'utf-8');
226
272
  fs.renameSync(tmpPath, configPath);
227
273
  }