oc-chatgpt-multi-auth 5.4.8 → 5.4.9

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/README.md CHANGED
@@ -35,6 +35,10 @@ What the installer does:
35
35
  - normalizes the plugin entry to `"oc-chatgpt-multi-auth"`
36
36
  - clears the cached plugin copy so OpenCode reinstalls the latest package
37
37
 
38
+ By default, the installer now writes a full catalog config that includes both:
39
+ - modern base model entries such as `gpt-5.4` for `--variant` workflows
40
+ - explicit preset entries such as `gpt-5.4-high` so the shipped catalog is visible directly in model pickers
41
+
38
42
  ## Example Usage
39
43
 
40
44
  ```bash
@@ -96,10 +100,10 @@ See [Architecture](docs/development/ARCHITECTURE.md) for implementation details.
96
100
 
97
101
  Use the quick-start path above for the fastest setup. For full setup, local development installs, legacy OpenCode support, and verification steps, see [Getting Started](docs/getting-started.md).
98
102
 
99
- If you are on OpenCode `v1.0.209` or earlier, use:
103
+ If you prefer the compact variant-only config on OpenCode `v1.0.210+`, use:
100
104
 
101
105
  ```bash
102
- npx -y oc-chatgpt-multi-auth@latest --legacy
106
+ npx -y oc-chatgpt-multi-auth@latest --modern
103
107
  ```
104
108
 
105
109
  ## Configuration
package/config/README.md CHANGED
@@ -9,6 +9,8 @@ This directory contains the official OpenCode config templates for the ChatGPT C
9
9
  | [`opencode-modern.json`](./opencode-modern.json) | **v1.0.210+** | Variant-based config: 9 base models with 34 total presets |
10
10
  | [`opencode-legacy.json`](./opencode-legacy.json) | **v1.0.209 and below** | Legacy explicit entries: 34 individual model definitions |
11
11
 
12
+ The installer currently uses a merged full-catalog mode by default so users get both the modern base entries and the explicit preset entries without having to hand-edit `opencode.json`.
13
+
12
14
  ## Quick pick
13
15
 
14
16
  If your OpenCode version is v1.0.210 or newer:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-chatgpt-multi-auth",
3
- "version": "5.4.8",
3
+ "version": "5.4.9",
4
4
  "description": "OpenCode plugin for using ChatGPT Plus/Pro in GPT-5 and Codex workflows with OAuth login, multi-account rotation, and guided setup",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,55 +1,97 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { existsSync } from "node:fs";
4
- import { readFile, writeFile, mkdir, copyFile, rm } from "node:fs/promises";
5
- import { fileURLToPath } from "node:url";
6
- import { dirname, join, resolve } from "node:path";
4
+ import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
7
5
  import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
8
 
9
9
  const PLUGIN_NAME = "oc-chatgpt-multi-auth";
10
+ const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
11
+ const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
10
12
 
11
- const args = new Set(process.argv.slice(2));
12
-
13
- if (args.has("--help") || args.has("-h")) {
13
+ function printHelp() {
14
14
  console.log(`Usage: ${PLUGIN_NAME} [--modern|--legacy] [--dry-run] [--no-cache-clear]\n\n` +
15
15
  "Default behavior:\n" +
16
16
  " - Installs/updates global config at ~/.config/opencode/opencode.json\n" +
17
- " - Uses modern config (variants) by default\n" +
17
+ " - Uses full catalog config by default (9 base models + 34 explicit presets)\n" +
18
18
  " - Ensures plugin is unpinned (latest)\n" +
19
19
  " - Clears OpenCode plugin cache\n\n" +
20
20
  "Options:\n" +
21
- " --modern Force modern config (default)\n" +
22
- " --legacy Use legacy config (older OpenCode versions)\n" +
21
+ " --modern Force compact modern config (9 base models + --variant presets)\n" +
22
+ " --legacy Force explicit legacy config (34 preset model entries)\n" +
23
23
  " --dry-run Show actions without writing\n" +
24
24
  " --no-cache-clear Skip clearing OpenCode cache\n"
25
25
  );
26
- process.exit(0);
27
26
  }
28
27
 
29
- const useLegacy = args.has("--legacy");
30
- const useModern = args.has("--modern") || !useLegacy;
31
- const dryRun = args.has("--dry-run");
32
- const skipCacheClear = args.has("--no-cache-clear");
33
-
34
28
  const scriptDir = dirname(fileURLToPath(import.meta.url));
35
29
  const repoRoot = resolve(scriptDir, "..");
36
- const templatePath = join(
37
- repoRoot,
38
- "config",
39
- useLegacy ? "opencode-legacy.json" : "opencode-modern.json"
40
- );
41
-
42
- const configDir = join(homedir(), ".config", "opencode");
43
- const configPath = join(configDir, "opencode.json");
44
- const cacheDir = join(homedir(), ".cache", "opencode");
45
- const cacheNodeModules = join(cacheDir, "node_modules", PLUGIN_NAME);
46
- const cacheBunLock = join(cacheDir, "bun.lock");
47
- const cachePackageJson = join(cacheDir, "package.json");
30
+ const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
31
+ const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
48
32
 
49
33
  function log(message) {
50
34
  console.log(message);
51
35
  }
52
36
 
37
+ function delay(ms) {
38
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
39
+ }
40
+
41
+ function isWindowsLockError(error) {
42
+ const code = error?.code;
43
+ return code === "EPERM" || code === "EBUSY";
44
+ }
45
+
46
+ function formatErrorForLog(error) {
47
+ if (error instanceof Error) {
48
+ return error.message;
49
+ }
50
+ return String(error);
51
+ }
52
+
53
+ function resolveHomeDirectory(env = process.env) {
54
+ return env.HOME || env.USERPROFILE || homedir();
55
+ }
56
+
57
+ function buildPaths(homeDir) {
58
+ const configDir = join(homeDir, ".config", "opencode");
59
+ const cacheDir = join(homeDir, ".cache", "opencode");
60
+ return {
61
+ configDir,
62
+ configPath: join(configDir, "opencode.json"),
63
+ cacheDir,
64
+ cacheNodeModules: join(cacheDir, "node_modules", PLUGIN_NAME),
65
+ cacheBunLock: join(cacheDir, "bun.lock"),
66
+ cachePackageJson: join(cacheDir, "package.json"),
67
+ modernTemplatePath,
68
+ legacyTemplatePath,
69
+ };
70
+ }
71
+
72
+ function parseCliArgs(argv = process.argv.slice(2)) {
73
+ const args = new Set(argv);
74
+ if (args.has("--help") || args.has("-h")) {
75
+ return {
76
+ wantsHelp: true,
77
+ };
78
+ }
79
+
80
+ const requestedModern = args.has("--modern");
81
+ const requestedLegacy = args.has("--legacy");
82
+
83
+ if (requestedModern && requestedLegacy) {
84
+ throw new Error("Choose only one of --modern or --legacy.");
85
+ }
86
+
87
+ return {
88
+ wantsHelp: false,
89
+ dryRun: args.has("--dry-run"),
90
+ skipCacheClear: args.has("--no-cache-clear"),
91
+ configMode: requestedModern ? "modern" : requestedLegacy ? "legacy" : "full",
92
+ };
93
+ }
94
+
53
95
  function normalizePluginList(list) {
54
96
  const entries = Array.isArray(list) ? list.filter(Boolean) : [];
55
97
  const filtered = entries.filter((entry) => {
@@ -63,12 +105,112 @@ function formatJson(obj) {
63
105
  return `${JSON.stringify(obj, null, 2)}\n`;
64
106
  }
65
107
 
108
+ function mergeFullTemplate(modernTemplate, legacyTemplate) {
109
+ const modernModels = modernTemplate.provider?.openai?.models ?? {};
110
+ const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
111
+ const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
112
+
113
+ if (overlappingKeys.length > 0) {
114
+ throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
115
+ }
116
+
117
+ return {
118
+ ...modernTemplate,
119
+ provider: {
120
+ ...(modernTemplate.provider ?? {}),
121
+ openai: {
122
+ ...(modernTemplate.provider?.openai ?? {}),
123
+ models: {
124
+ ...modernModels,
125
+ ...legacyModels,
126
+ },
127
+ },
128
+ },
129
+ };
130
+ }
131
+
66
132
  async function readJson(filePath) {
67
133
  const content = await readFile(filePath, "utf-8");
68
134
  return JSON.parse(content);
69
135
  }
70
136
 
71
- async function backupConfig(sourcePath) {
137
+ async function renameWithWindowsRetry(sourcePath, destinationPath) {
138
+ let lastError = null;
139
+
140
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
141
+ try {
142
+ await rename(sourcePath, destinationPath);
143
+ return;
144
+ } catch (error) {
145
+ if (isWindowsLockError(error)) {
146
+ // Windows desktop installs often see brief AV/indexer locks on config
147
+ // files, so retry the atomic rename before surfacing a hard failure.
148
+ lastError = error;
149
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
150
+ continue;
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ if (lastError) {
157
+ throw lastError;
158
+ }
159
+ }
160
+
161
+ async function writeFileAtomic(filePath, content) {
162
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
163
+ const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
164
+
165
+ try {
166
+ await mkdir(dirname(filePath), { recursive: true });
167
+ await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
168
+ await renameWithWindowsRetry(tempPath, filePath);
169
+ } catch (error) {
170
+ await rm(tempPath, { force: true }).catch(() => {});
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ async function loadTemplate(mode, paths) {
176
+ if (mode === "modern") {
177
+ return readJson(paths.modernTemplatePath);
178
+ }
179
+ if (mode === "legacy") {
180
+ return readJson(paths.legacyTemplatePath);
181
+ }
182
+
183
+ const [modernTemplate, legacyTemplate] = await Promise.all([
184
+ readJson(paths.modernTemplatePath),
185
+ readJson(paths.legacyTemplatePath),
186
+ ]);
187
+
188
+ return mergeFullTemplate(modernTemplate, legacyTemplate);
189
+ }
190
+
191
+ async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
192
+ let lastError = null;
193
+
194
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
195
+ try {
196
+ await copyFile(sourcePath, destinationPath);
197
+ return;
198
+ } catch (error) {
199
+ if (isWindowsLockError(error)) {
200
+ lastError = error;
201
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
202
+ continue;
203
+ }
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ if (lastError) {
209
+ throw lastError;
210
+ }
211
+ }
212
+
213
+ async function backupConfig(sourcePath, dryRun) {
72
214
  const timestamp = new Date()
73
215
  .toISOString()
74
216
  .replace(/[:.]/g, "-")
@@ -76,21 +218,21 @@ async function backupConfig(sourcePath) {
76
218
  .replace("Z", "");
77
219
  const backupPath = `${sourcePath}.bak-${timestamp}`;
78
220
  if (!dryRun) {
79
- await copyFile(sourcePath, backupPath);
221
+ await copyFileWithWindowsRetry(sourcePath, backupPath);
80
222
  }
81
223
  return backupPath;
82
224
  }
83
225
 
84
- async function removePluginFromCachePackage() {
85
- if (!existsSync(cachePackageJson)) {
226
+ async function removePluginFromCachePackage(paths, dryRun) {
227
+ if (!existsSync(paths.cachePackageJson)) {
86
228
  return;
87
229
  }
88
230
 
89
231
  let cacheData;
90
232
  try {
91
- cacheData = await readJson(cachePackageJson);
233
+ cacheData = await readJson(paths.cachePackageJson);
92
234
  } catch (error) {
93
- log(`Warning: Could not parse ${cachePackageJson} (${error}). Skipping.`);
235
+ log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
94
236
  return;
95
237
  }
96
238
 
@@ -115,45 +257,63 @@ async function removePluginFromCachePackage() {
115
257
  }
116
258
 
117
259
  if (dryRun) {
118
- log(`[dry-run] Would update ${cachePackageJson} to remove ${PLUGIN_NAME}`);
260
+ log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${PLUGIN_NAME}`);
119
261
  return;
120
262
  }
121
263
 
122
- await writeFile(cachePackageJson, formatJson(cacheData), "utf-8");
264
+ await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
123
265
  }
124
266
 
125
- async function clearCache() {
267
+ async function clearCache(paths, dryRun, skipCacheClear) {
126
268
  if (skipCacheClear) {
127
269
  log("Skipping cache clear (--no-cache-clear).");
270
+ await removePluginFromCachePackage(paths, dryRun);
128
271
  return;
129
272
  }
130
273
 
131
274
  if (dryRun) {
132
- log(`[dry-run] Would remove ${cacheNodeModules}`);
133
- log(`[dry-run] Would remove ${cacheBunLock}`);
275
+ log(`[dry-run] Would remove ${paths.cacheNodeModules}`);
276
+ log(`[dry-run] Would remove ${paths.cacheBunLock}`);
134
277
  } else {
135
- await rm(cacheNodeModules, { recursive: true, force: true });
136
- await rm(cacheBunLock, { force: true });
278
+ await rm(paths.cacheNodeModules, { recursive: true, force: true });
279
+ await rm(paths.cacheBunLock, { force: true });
137
280
  }
138
281
 
139
- await removePluginFromCachePackage();
282
+ await removePluginFromCachePackage(paths, dryRun);
140
283
  }
141
284
 
142
- async function main() {
143
- if (!existsSync(templatePath)) {
144
- throw new Error(`Config template not found at ${templatePath}`);
285
+ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
286
+ const parsed = parseCliArgs(argv);
287
+ if (parsed.wantsHelp) {
288
+ printHelp();
289
+ return { exitCode: 0, action: "help" };
145
290
  }
146
291
 
147
- const template = await readJson(templatePath);
292
+ const { env = process.env } = options;
293
+ const { configMode, dryRun, skipCacheClear } = parsed;
294
+ const paths = buildPaths(resolveHomeDirectory(env));
295
+ const requiredTemplatePaths = configMode === "modern"
296
+ ? [paths.modernTemplatePath]
297
+ : configMode === "legacy"
298
+ ? [paths.legacyTemplatePath]
299
+ : [paths.modernTemplatePath, paths.legacyTemplatePath];
300
+
301
+ for (const templatePath of requiredTemplatePaths) {
302
+ if (!existsSync(templatePath)) {
303
+ throw new Error(`Config template not found at ${templatePath}`);
304
+ }
305
+ }
306
+
307
+ const template = await loadTemplate(configMode, paths);
148
308
  template.plugin = [PLUGIN_NAME];
149
309
 
150
310
  let nextConfig = template;
151
- if (existsSync(configPath)) {
152
- const backupPath = await backupConfig(configPath);
311
+ if (existsSync(paths.configPath)) {
312
+ const backupPath = await backupConfig(paths.configPath, dryRun);
153
313
  log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`);
154
314
 
155
315
  try {
156
- const existing = await readJson(configPath);
316
+ const existing = await readJson(paths.configPath);
157
317
  const merged = { ...existing };
158
318
  merged.plugin = normalizePluginList(existing.plugin);
159
319
  const provider = (existing.provider && typeof existing.provider === "object")
@@ -163,7 +323,9 @@ async function main() {
163
323
  merged.provider = provider;
164
324
  nextConfig = merged;
165
325
  } catch (error) {
166
- log(`Warning: Could not parse existing config (${error}). Replacing with template.`);
326
+ // Only log the filesystem/parser message. Never echo config bodies because
327
+ // opencode.json may carry provider credentials or tokens.
328
+ log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
167
329
  nextConfig = template;
168
330
  }
169
331
  } else {
@@ -171,23 +333,50 @@ async function main() {
171
333
  }
172
334
 
173
335
  if (dryRun) {
174
- log(`[dry-run] Would write ${configPath} using ${useLegacy ? "legacy" : "modern"} config`);
336
+ log(`[dry-run] Would write ${paths.configPath} using ${configMode} config`);
175
337
  } else {
176
- await mkdir(configDir, { recursive: true });
177
- await writeFile(configPath, formatJson(nextConfig), "utf-8");
178
- log(`Wrote ${configPath} (${useLegacy ? "legacy" : "modern"} config)`);
338
+ // Persist through a temp file plus rename so Windows AV/file locks do not
339
+ // leave a truncated opencode.json behind during installer updates.
340
+ await writeFileAtomic(paths.configPath, formatJson(nextConfig));
341
+ log(`Wrote ${paths.configPath} (${configMode} config)`);
179
342
  }
180
343
 
181
- await clearCache();
344
+ await clearCache(paths, dryRun, skipCacheClear);
182
345
 
183
346
  log("\nDone. Restart OpenCode to (re)install the plugin.");
184
347
  log("Example: opencode");
185
- if (useLegacy) {
186
- log("Note: Legacy config requires OpenCode v1.0.209 or older.");
348
+ if (configMode === "modern") {
349
+ log("Note: Modern config intentionally shows 9 base model entries; use --variant to access all 34 shipped presets.");
350
+ }
351
+ if (configMode === "legacy") {
352
+ log("Note: Legacy config writes 34 explicit preset entries and is also safe for older OpenCode versions.");
353
+ }
354
+ if (configMode === "full") {
355
+ log("Note: Full config installs both modern base models and explicit preset entries so the full shipped catalog is visible by default.");
187
356
  }
357
+
358
+ return {
359
+ exitCode: 0,
360
+ action: "install",
361
+ configMode,
362
+ configPath: paths.configPath,
363
+ };
188
364
  }
189
365
 
190
- main().catch((error) => {
191
- console.error(`Installer failed: ${error instanceof Error ? error.message : error}`);
192
- process.exit(1);
193
- });
366
+ export const __test = {
367
+ buildPaths,
368
+ backupConfig,
369
+ copyFileWithWindowsRetry,
370
+ mergeFullTemplate,
371
+ parseCliArgs,
372
+ writeFileAtomic,
373
+ renameWithWindowsRetry,
374
+ resolveHomeDirectory,
375
+ };
376
+
377
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
378
+ runInstaller().catch((error) => {
379
+ console.error(`Installer failed: ${formatErrorForLog(error)}`);
380
+ process.exit(1);
381
+ });
382
+ }