opencode-account-manager 0.4.1
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/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +183 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/accounts.d.ts +19 -0
- package/dist/core/accounts.d.ts.map +1 -0
- package/dist/core/accounts.js +181 -0
- package/dist/core/accounts.js.map +1 -0
- package/dist/core/config-store.d.ts +48 -0
- package/dist/core/config-store.d.ts.map +1 -0
- package/dist/core/config-store.js +206 -0
- package/dist/core/config-store.js.map +1 -0
- package/dist/core/crypto.d.ts +40 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +172 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/importers/amJson.d.ts +17 -0
- package/dist/core/importers/amJson.d.ts.map +1 -0
- package/dist/core/importers/amJson.js +131 -0
- package/dist/core/importers/amJson.js.map +1 -0
- package/dist/core/opencode-config.d.ts +92 -0
- package/dist/core/opencode-config.d.ts.map +1 -0
- package/dist/core/opencode-config.js +148 -0
- package/dist/core/opencode-config.js.map +1 -0
- package/dist/core/paths.d.ts +5 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/types.d.ts +74 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +30 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/utils.d.ts +5 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +35 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/tui/Dashboard.d.ts +7 -0
- package/dist/tui/Dashboard.d.ts.map +1 -0
- package/dist/tui/Dashboard.js +331 -0
- package/dist/tui/Dashboard.js.map +1 -0
- package/dist/tui/components/AccountList.d.ts +18 -0
- package/dist/tui/components/AccountList.d.ts.map +1 -0
- package/dist/tui/components/AccountList.js +92 -0
- package/dist/tui/components/AccountList.js.map +1 -0
- package/dist/tui/components/Box.d.ts +11 -0
- package/dist/tui/components/Box.d.ts.map +1 -0
- package/dist/tui/components/Box.js +15 -0
- package/dist/tui/components/Box.js.map +1 -0
- package/dist/tui/components/ExportModal.d.ts +10 -0
- package/dist/tui/components/ExportModal.d.ts.map +1 -0
- package/dist/tui/components/ExportModal.js +192 -0
- package/dist/tui/components/ExportModal.js.map +1 -0
- package/dist/tui/components/FileBrowser.d.ts +12 -0
- package/dist/tui/components/FileBrowser.d.ts.map +1 -0
- package/dist/tui/components/FileBrowser.js +349 -0
- package/dist/tui/components/FileBrowser.js.map +1 -0
- package/dist/tui/components/Header.d.ts +8 -0
- package/dist/tui/components/Header.d.ts.map +1 -0
- package/dist/tui/components/Header.js +20 -0
- package/dist/tui/components/Header.js.map +1 -0
- package/dist/tui/components/ImportModal.d.ts +10 -0
- package/dist/tui/components/ImportModal.d.ts.map +1 -0
- package/dist/tui/components/ImportModal.js +215 -0
- package/dist/tui/components/ImportModal.js.map +1 -0
- package/dist/tui/components/McpServerList.d.ts +8 -0
- package/dist/tui/components/McpServerList.d.ts.map +1 -0
- package/dist/tui/components/McpServerList.js +35 -0
- package/dist/tui/components/McpServerList.js.map +1 -0
- package/dist/tui/components/Menu.d.ts +10 -0
- package/dist/tui/components/Menu.d.ts.map +1 -0
- package/dist/tui/components/Menu.js +83 -0
- package/dist/tui/components/Menu.js.map +1 -0
- package/dist/tui/components/PasswordInput.d.ts +12 -0
- package/dist/tui/components/PasswordInput.d.ts.map +1 -0
- package/dist/tui/components/PasswordInput.js +130 -0
- package/dist/tui/components/PasswordInput.js.map +1 -0
- package/dist/tui/components/ProviderList.d.ts +8 -0
- package/dist/tui/components/ProviderList.d.ts.map +1 -0
- package/dist/tui/components/ProviderList.js +37 -0
- package/dist/tui/components/ProviderList.js.map +1 -0
- package/dist/tui/components/SectionBox.d.ts +10 -0
- package/dist/tui/components/SectionBox.d.ts.map +1 -0
- package/dist/tui/components/SectionBox.js +16 -0
- package/dist/tui/components/SectionBox.js.map +1 -0
- package/dist/tui/components/StatsRow.d.ts +13 -0
- package/dist/tui/components/StatsRow.d.ts.map +1 -0
- package/dist/tui/components/StatsRow.js +18 -0
- package/dist/tui/components/StatsRow.js.map +1 -0
- package/dist/tui/components/StatusBadge.d.ts +8 -0
- package/dist/tui/components/StatusBadge.d.ts.map +1 -0
- package/dist/tui/components/StatusBadge.js +30 -0
- package/dist/tui/components/StatusBadge.js.map +1 -0
- package/dist/tui/components/index.d.ts +14 -0
- package/dist/tui/components/index.d.ts.map +1 -0
- package/dist/tui/components/index.js +32 -0
- package/dist/tui/components/index.js.map +1 -0
- package/dist/tui/index.d.ts +5 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +13 -0
- package/dist/tui/index.js.map +1 -0
- package/docs/BLUEPRINT.md +476 -0
- package/docs/ROADMAP.md +74 -0
- package/package.json +38 -0
- package/src/cli.ts +207 -0
- package/src/core/accounts.ts +215 -0
- package/src/core/config-store.ts +212 -0
- package/src/core/crypto.ts +162 -0
- package/src/core/importers/amJson.ts +185 -0
- package/src/core/opencode-config.ts +217 -0
- package/src/core/paths.ts +32 -0
- package/src/core/types.ts +118 -0
- package/src/core/utils.ts +28 -0
- package/src/tui/Dashboard.tsx +431 -0
- package/src/tui/components/AccountList.tsx +155 -0
- package/src/tui/components/Box.tsx +37 -0
- package/src/tui/components/ExportModal.tsx +255 -0
- package/src/tui/components/FileBrowser.tsx +393 -0
- package/src/tui/components/Header.tsx +26 -0
- package/src/tui/components/ImportModal.tsx +288 -0
- package/src/tui/components/McpServerList.tsx +67 -0
- package/src/tui/components/Menu.tsx +103 -0
- package/src/tui/components/PasswordInput.tsx +159 -0
- package/src/tui/components/ProviderList.tsx +61 -0
- package/src/tui/components/SectionBox.tsx +35 -0
- package/src/tui/components/StatsRow.tsx +33 -0
- package/src/tui/components/StatusBadge.tsx +33 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/index.tsx +11 -0
- package/tsconfig.json +20 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import {
|
|
7
|
+
buildPortableExport,
|
|
8
|
+
createEmptyPluginAccountsFile,
|
|
9
|
+
extractAccountsFromImport,
|
|
10
|
+
mergeAccounts,
|
|
11
|
+
readPluginAccountsFile,
|
|
12
|
+
summarizeAccounts,
|
|
13
|
+
writePluginAccountsFile,
|
|
14
|
+
} from "./core/accounts";
|
|
15
|
+
import { getPluginAccountsPath, getAmFolderPath } from "./core/paths";
|
|
16
|
+
import { importFromAmFolder, isAmFolder } from "./core/importers/amJson";
|
|
17
|
+
import { writeJsonFile } from "./core/utils";
|
|
18
|
+
import { startTuiDashboard } from "./tui";
|
|
19
|
+
|
|
20
|
+
function formatTimestamp(timestamp?: number): string {
|
|
21
|
+
if (!timestamp) return "-";
|
|
22
|
+
return new Date(timestamp).toLocaleString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isLimited(rateLimitResetTimes?: Record<string, number>): boolean {
|
|
26
|
+
if (!rateLimitResetTimes) return false;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
return Object.values(rateLimitResetTimes).some((value) => value > now);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeReadPluginFile(pluginPath: string) {
|
|
32
|
+
try {
|
|
33
|
+
return readPluginAccountsFile(pluginPath);
|
|
34
|
+
} catch {
|
|
35
|
+
return createEmptyPluginAccountsFile();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const program = new Command();
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.name("ocam")
|
|
43
|
+
.description("OpenCode Account Manager - TUI dashboard and CLI for managing accounts")
|
|
44
|
+
.version("0.4.0");
|
|
45
|
+
|
|
46
|
+
// Default command - show dashboard
|
|
47
|
+
program
|
|
48
|
+
.command("dashboard", { isDefault: true })
|
|
49
|
+
.description("Start the TUI dashboard (default)")
|
|
50
|
+
.option("--plugin-path <path>", "Path to plugin accounts file")
|
|
51
|
+
.action((options) => {
|
|
52
|
+
startTuiDashboard({
|
|
53
|
+
pluginPath: options.pluginPath,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command("list")
|
|
59
|
+
.description("List plugin accounts")
|
|
60
|
+
.option("--plugin-path <path>", "Path to plugin accounts file")
|
|
61
|
+
.action((options) => {
|
|
62
|
+
const pluginPath = getPluginAccountsPath(options.pluginPath);
|
|
63
|
+
const file = safeReadPluginFile(pluginPath);
|
|
64
|
+
const summary = summarizeAccounts(file.accounts);
|
|
65
|
+
|
|
66
|
+
console.log(chalk.bold("=== Account List ===\n"));
|
|
67
|
+
console.log(`Total: ${summary.total} | Available: ${chalk.green(summary.available)} | Limited: ${chalk.yellow(summary.limited)}\n`);
|
|
68
|
+
|
|
69
|
+
for (const account of file.accounts) {
|
|
70
|
+
const limited = isLimited(account.rateLimitResetTimes);
|
|
71
|
+
const status = limited ? chalk.yellow("[LIMITED]") : chalk.green("[ OK ]");
|
|
72
|
+
console.log(`${status} ${account.email}`);
|
|
73
|
+
|
|
74
|
+
if (limited && account.rateLimitResetTimes) {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
for (const [model, resetTime] of Object.entries(account.rateLimitResetTimes)) {
|
|
77
|
+
if (resetTime > now) {
|
|
78
|
+
const hours = ((resetTime - now) / 3600000).toFixed(1);
|
|
79
|
+
console.log(chalk.gray(` └─ ${model}: ${hours}h`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
program
|
|
87
|
+
.command("export")
|
|
88
|
+
.description("Export accounts to a portable JSON file")
|
|
89
|
+
.option("--plugin-path <path>", "Path to plugin accounts file")
|
|
90
|
+
.option("-o, --out <path>", "Output file path", `antigravity-export-${Date.now()}.json`)
|
|
91
|
+
.action((options) => {
|
|
92
|
+
const pluginPath = getPluginAccountsPath(options.pluginPath);
|
|
93
|
+
const file = safeReadPluginFile(pluginPath);
|
|
94
|
+
|
|
95
|
+
if (file.accounts.length === 0) {
|
|
96
|
+
console.log(chalk.yellow("No accounts to export."));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const exportFile = buildPortableExport(file.accounts);
|
|
101
|
+
writeJsonFile(options.out, exportFile);
|
|
102
|
+
console.log(chalk.green(`Exported ${file.accounts.length} accounts to ${options.out}`));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
program
|
|
106
|
+
.command("import")
|
|
107
|
+
.description("Import accounts from file or AM folder")
|
|
108
|
+
.argument("<source>", "Path to JSON file or AM folder (~/.antigravity_tools)")
|
|
109
|
+
.option("--plugin-path <path>", "Path to plugin accounts file")
|
|
110
|
+
.option("-m, --mode <merge|replace>", "Import mode", "merge")
|
|
111
|
+
.action((source, options) => {
|
|
112
|
+
const pluginPath = getPluginAccountsPath(options.pluginPath);
|
|
113
|
+
const mode = options.mode === "replace" ? "replace" : "merge";
|
|
114
|
+
const existingFile = safeReadPluginFile(pluginPath);
|
|
115
|
+
const beforeCount = existingFile.accounts.length;
|
|
116
|
+
|
|
117
|
+
let incomingAccounts: any[] = [];
|
|
118
|
+
let sourceType = "";
|
|
119
|
+
|
|
120
|
+
// Check if source is AM folder
|
|
121
|
+
if (fs.existsSync(source) && fs.statSync(source).isDirectory()) {
|
|
122
|
+
if (isAmFolder(source)) {
|
|
123
|
+
sourceType = "AM folder";
|
|
124
|
+
const result = importFromAmFolder(source);
|
|
125
|
+
|
|
126
|
+
if (result.errors.length > 0) {
|
|
127
|
+
console.log(chalk.red(`Error: ${result.errors.join(", ")}`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
incomingAccounts = result.accounts;
|
|
132
|
+
|
|
133
|
+
if (result.skipped.length > 0) {
|
|
134
|
+
console.log(chalk.gray(`Skipped: ${result.skipped.length} accounts`));
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
console.log(chalk.red("Directory is not a valid AM folder"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
} else if (fs.existsSync(source)) {
|
|
141
|
+
// It's a file
|
|
142
|
+
sourceType = "JSON file";
|
|
143
|
+
try {
|
|
144
|
+
const raw = fs.readFileSync(source, "utf8");
|
|
145
|
+
const data = JSON.parse(raw);
|
|
146
|
+
incomingAccounts = extractAccountsFromImport(data);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.log(chalk.red(`Failed to parse file: ${err}`));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
console.log(chalk.red(`Source not found: ${source}`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (incomingAccounts.length === 0) {
|
|
157
|
+
console.log(chalk.yellow("No valid accounts found in source."));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const merged = mergeAccounts(existingFile, incomingAccounts, mode);
|
|
162
|
+
writePluginAccountsFile(pluginPath, merged);
|
|
163
|
+
|
|
164
|
+
const added = merged.accounts.length - beforeCount;
|
|
165
|
+
console.log(chalk.green(`\nImport complete!`));
|
|
166
|
+
console.log(`Source: ${sourceType}`);
|
|
167
|
+
console.log(`Mode: ${mode}`);
|
|
168
|
+
console.log(`Found: ${incomingAccounts.length} accounts`);
|
|
169
|
+
console.log(`Added: ${added} new`);
|
|
170
|
+
console.log(`Total: ${merged.accounts.length} accounts`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
program
|
|
174
|
+
.command("import-am")
|
|
175
|
+
.description("Import accounts from Antigravity Manager")
|
|
176
|
+
.option("--am-path <path>", "Path to AM folder", getAmFolderPath())
|
|
177
|
+
.option("--plugin-path <path>", "Path to plugin accounts file")
|
|
178
|
+
.option("-m, --mode <merge|replace>", "Import mode", "merge")
|
|
179
|
+
.action((options) => {
|
|
180
|
+
const pluginPath = getPluginAccountsPath(options.pluginPath);
|
|
181
|
+
const result = importFromAmFolder(options.amPath);
|
|
182
|
+
|
|
183
|
+
if (result.errors.length > 0) {
|
|
184
|
+
console.log(chalk.red(`Error: ${result.errors.join(", ")}`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (result.accounts.length === 0) {
|
|
189
|
+
console.log(chalk.yellow("No accounts found in AM."));
|
|
190
|
+
if (result.skipped.length > 0) {
|
|
191
|
+
console.log(chalk.gray(`Skipped: ${result.skipped.join(", ")}`));
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const mode = options.mode === "replace" ? "replace" : "merge";
|
|
197
|
+
const existingFile = safeReadPluginFile(pluginPath);
|
|
198
|
+
const merged = mergeAccounts(existingFile, result.accounts, mode);
|
|
199
|
+
writePluginAccountsFile(pluginPath, merged);
|
|
200
|
+
|
|
201
|
+
console.log(chalk.green(`\nImported from AM!`));
|
|
202
|
+
console.log(`Found: ${result.accounts.length} accounts`);
|
|
203
|
+
console.log(`Skipped: ${result.skipped.length}`);
|
|
204
|
+
console.log(`Total: ${merged.accounts.length} accounts`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
program.parse();
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
Account,
|
|
5
|
+
PluginAccountsFile,
|
|
6
|
+
PortableExportFile,
|
|
7
|
+
RateLimitResetTimes,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { getPluginAccountsPath } from "./paths";
|
|
10
|
+
import { readJsonFile, toLowerTrim, writeJsonFile } from "./utils";
|
|
11
|
+
|
|
12
|
+
export type MergeMode = "merge" | "replace";
|
|
13
|
+
export type ImportFormat = "portable" | "plugin";
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAccountCandidate(value: unknown): value is Account {
|
|
20
|
+
if (!isRecord(value)) return false;
|
|
21
|
+
if (typeof value.email !== "string") return false;
|
|
22
|
+
return value.email.includes("@");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeAccount(account: Account): Account {
|
|
26
|
+
return {
|
|
27
|
+
...account,
|
|
28
|
+
email: account.email.trim(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizePluginAccounts(data: PluginAccountsFile): PluginAccountsFile {
|
|
33
|
+
const accounts = Array.isArray(data.accounts)
|
|
34
|
+
? data.accounts.filter(isAccountCandidate).map(normalizeAccount)
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
version: typeof data.version === "number" ? data.version : 3,
|
|
39
|
+
accounts,
|
|
40
|
+
activeIndex: typeof data.activeIndex === "number" ? data.activeIndex : undefined,
|
|
41
|
+
activeIndexByFamily:
|
|
42
|
+
data.activeIndexByFamily && isRecord(data.activeIndexByFamily)
|
|
43
|
+
? (data.activeIndexByFamily as Record<string, number>)
|
|
44
|
+
: undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createEmptyPluginAccountsFile(): PluginAccountsFile {
|
|
49
|
+
return {
|
|
50
|
+
version: 3,
|
|
51
|
+
accounts: [],
|
|
52
|
+
activeIndex: 0,
|
|
53
|
+
activeIndexByFamily: {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readPluginAccountsFile(filePath?: string): PluginAccountsFile {
|
|
58
|
+
const resolvedPath = getPluginAccountsPath(filePath);
|
|
59
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Plugin accounts file not found at ${resolvedPath}. ` +
|
|
62
|
+
"Login at least one account first or provide --plugin-path."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const data = readJsonFile<PluginAccountsFile>(resolvedPath);
|
|
66
|
+
return normalizePluginAccounts(data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writePluginAccountsFile(
|
|
70
|
+
filePath: string | undefined,
|
|
71
|
+
data: PluginAccountsFile
|
|
72
|
+
): void {
|
|
73
|
+
const resolvedPath = getPluginAccountsPath(filePath);
|
|
74
|
+
const normalized = normalizePluginAccounts(data);
|
|
75
|
+
const dir = path.dirname(resolvedPath);
|
|
76
|
+
if (!fs.existsSync(dir)) {
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
writeJsonFile(resolvedPath, normalized);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mergeRateLimits(
|
|
83
|
+
existing?: RateLimitResetTimes,
|
|
84
|
+
incoming?: RateLimitResetTimes
|
|
85
|
+
): RateLimitResetTimes | undefined {
|
|
86
|
+
if (!existing && !incoming) return undefined;
|
|
87
|
+
return { ...(existing || {}), ...(incoming || {}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mergeFingerprintHistory(
|
|
91
|
+
existing?: Account["fingerprintHistory"],
|
|
92
|
+
incoming?: Account["fingerprintHistory"]
|
|
93
|
+
): Account["fingerprintHistory"] | undefined {
|
|
94
|
+
if (!existing && !incoming) return undefined;
|
|
95
|
+
const list = [...(existing || []), ...(incoming || [])];
|
|
96
|
+
return list.length > 0 ? list : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function mergeAccount(existing: Account, incoming: Account): Account {
|
|
100
|
+
return {
|
|
101
|
+
...existing,
|
|
102
|
+
...incoming,
|
|
103
|
+
email: incoming.email || existing.email,
|
|
104
|
+
refreshToken: incoming.refreshToken || existing.refreshToken,
|
|
105
|
+
fingerprint: incoming.fingerprint || existing.fingerprint,
|
|
106
|
+
rateLimitResetTimes: mergeRateLimits(
|
|
107
|
+
existing.rateLimitResetTimes,
|
|
108
|
+
incoming.rateLimitResetTimes
|
|
109
|
+
),
|
|
110
|
+
fingerprintHistory: mergeFingerprintHistory(
|
|
111
|
+
existing.fingerprintHistory,
|
|
112
|
+
incoming.fingerprintHistory
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function mergeAccounts(
|
|
118
|
+
existingFile: PluginAccountsFile,
|
|
119
|
+
incomingAccounts: Account[],
|
|
120
|
+
mode: MergeMode
|
|
121
|
+
): PluginAccountsFile {
|
|
122
|
+
const base = normalizePluginAccounts(existingFile);
|
|
123
|
+
const incoming = incomingAccounts
|
|
124
|
+
.filter(isAccountCandidate)
|
|
125
|
+
.map(normalizeAccount);
|
|
126
|
+
|
|
127
|
+
if (mode === "replace") {
|
|
128
|
+
return {
|
|
129
|
+
version: base.version || 3,
|
|
130
|
+
accounts: incoming,
|
|
131
|
+
activeIndex: 0,
|
|
132
|
+
activeIndexByFamily: {},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const byEmail = new Map<string, Account>();
|
|
137
|
+
for (const account of base.accounts) {
|
|
138
|
+
byEmail.set(toLowerTrim(account.email), account);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const account of incoming) {
|
|
142
|
+
const key = toLowerTrim(account.email);
|
|
143
|
+
const existing = byEmail.get(key);
|
|
144
|
+
if (existing) {
|
|
145
|
+
byEmail.set(key, mergeAccount(existing, account));
|
|
146
|
+
} else {
|
|
147
|
+
byEmail.set(key, account);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...base,
|
|
153
|
+
accounts: Array.from(byEmail.values()),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function buildPortableExport(accounts: Account[]): PortableExportFile {
|
|
158
|
+
return {
|
|
159
|
+
version: 1,
|
|
160
|
+
exportedAt: Date.now(),
|
|
161
|
+
exportedFrom: "antigravity-sync",
|
|
162
|
+
accounts: accounts.map(normalizeAccount),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function detectImportFormat(data: unknown): ImportFormat | "unknown" {
|
|
167
|
+
if (!isRecord(data)) return "unknown";
|
|
168
|
+
if (data.exportedFrom === "antigravity-sync") return "portable";
|
|
169
|
+
if (typeof data.version === "number" && Array.isArray(data.accounts)) {
|
|
170
|
+
return "plugin";
|
|
171
|
+
}
|
|
172
|
+
return "unknown";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function extractAccountsFromImport(
|
|
176
|
+
data: unknown,
|
|
177
|
+
format?: ImportFormat
|
|
178
|
+
): Account[] {
|
|
179
|
+
if (Array.isArray(data)) {
|
|
180
|
+
return data.filter(isAccountCandidate).map(normalizeAccount);
|
|
181
|
+
}
|
|
182
|
+
if (!isRecord(data)) {
|
|
183
|
+
throw new Error("Import data must be an object or an array of accounts.");
|
|
184
|
+
}
|
|
185
|
+
const detected = format || detectImportFormat(data);
|
|
186
|
+
if (detected === "unknown") {
|
|
187
|
+
throw new Error("Unsupported import format. Provide a portable export or plugin file.");
|
|
188
|
+
}
|
|
189
|
+
const accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
190
|
+
return accounts.filter(isAccountCandidate).map(normalizeAccount);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sanitizeAccountForPublic(account: Account): Account {
|
|
194
|
+
const { refreshToken, ...rest } = account;
|
|
195
|
+
return rest;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function summarizeAccounts(accounts: Account[]): {
|
|
199
|
+
total: number;
|
|
200
|
+
limited: number;
|
|
201
|
+
available: number;
|
|
202
|
+
} {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
let limited = 0;
|
|
205
|
+
for (const account of accounts) {
|
|
206
|
+
const resets = account.rateLimitResetTimes || {};
|
|
207
|
+
const isLimited = Object.values(resets).some((value) => value > now);
|
|
208
|
+
if (isLimited) limited += 1;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
total: accounts.length,
|
|
212
|
+
limited,
|
|
213
|
+
available: accounts.length - limited,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export interface AppConfig {
|
|
10
|
+
lastExportFolder?: string;
|
|
11
|
+
lastImportFolder?: string;
|
|
12
|
+
defaultExportFormat?: "encrypted" | "plain";
|
|
13
|
+
recentFolders?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Constants
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const CONFIG_FILENAME = "ocam-config.json";
|
|
21
|
+
const MAX_RECENT_FOLDERS = 5;
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Path Functions
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the config directory path
|
|
29
|
+
*/
|
|
30
|
+
function getConfigDir(): string {
|
|
31
|
+
if (process.platform === "win32") {
|
|
32
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
33
|
+
return path.join(appData, "opencode");
|
|
34
|
+
}
|
|
35
|
+
return path.join(os.homedir(), ".config", "opencode");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the full path to config file
|
|
40
|
+
*/
|
|
41
|
+
export function getConfigPath(): string {
|
|
42
|
+
return path.join(getConfigDir(), CONFIG_FILENAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Config Functions
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read config file, return empty config if not exists
|
|
51
|
+
*/
|
|
52
|
+
export function readConfig(): AppConfig {
|
|
53
|
+
const configPath = getConfigPath();
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(configPath)) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
61
|
+
return JSON.parse(content) as AppConfig;
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Write config file
|
|
69
|
+
*/
|
|
70
|
+
export function writeConfig(config: AppConfig): void {
|
|
71
|
+
const configPath = getConfigPath();
|
|
72
|
+
const configDir = path.dirname(configPath);
|
|
73
|
+
|
|
74
|
+
// Ensure directory exists
|
|
75
|
+
if (!fs.existsSync(configDir)) {
|
|
76
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const content = JSON.stringify(config, null, 2);
|
|
80
|
+
fs.writeFileSync(configPath, content, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update last export folder and add to recent folders
|
|
85
|
+
*/
|
|
86
|
+
export function updateLastExportFolder(folder: string): void {
|
|
87
|
+
const config = readConfig();
|
|
88
|
+
config.lastExportFolder = folder;
|
|
89
|
+
config.recentFolders = addToRecentFolders(config.recentFolders || [], folder);
|
|
90
|
+
writeConfig(config);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update last import folder and add to recent folders
|
|
95
|
+
*/
|
|
96
|
+
export function updateLastImportFolder(folder: string): void {
|
|
97
|
+
const config = readConfig();
|
|
98
|
+
config.lastImportFolder = folder;
|
|
99
|
+
config.recentFolders = addToRecentFolders(config.recentFolders || [], folder);
|
|
100
|
+
writeConfig(config);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get recent folders list
|
|
105
|
+
*/
|
|
106
|
+
export function getRecentFolders(): string[] {
|
|
107
|
+
const config = readConfig();
|
|
108
|
+
return config.recentFolders || [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get last export folder or default
|
|
113
|
+
*/
|
|
114
|
+
export function getLastExportFolder(): string {
|
|
115
|
+
const config = readConfig();
|
|
116
|
+
return config.lastExportFolder || process.cwd();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get last import folder or default
|
|
121
|
+
*/
|
|
122
|
+
export function getLastImportFolder(): string {
|
|
123
|
+
const config = readConfig();
|
|
124
|
+
return config.lastImportFolder || process.cwd();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Helper Functions
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Add folder to recent list, keeping max items and removing duplicates
|
|
133
|
+
*/
|
|
134
|
+
function addToRecentFolders(recent: string[], folder: string): string[] {
|
|
135
|
+
// Normalize path
|
|
136
|
+
const normalized = path.normalize(folder);
|
|
137
|
+
|
|
138
|
+
// Remove if already exists
|
|
139
|
+
const filtered = recent.filter(f => path.normalize(f) !== normalized);
|
|
140
|
+
|
|
141
|
+
// Add to front
|
|
142
|
+
filtered.unshift(normalized);
|
|
143
|
+
|
|
144
|
+
// Keep only max items
|
|
145
|
+
return filtered.slice(0, MAX_RECENT_FOLDERS);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Quick Locations
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
export interface QuickLocation {
|
|
153
|
+
label: string;
|
|
154
|
+
path: string;
|
|
155
|
+
exists: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get list of quick locations for file browser
|
|
160
|
+
*/
|
|
161
|
+
export function getQuickLocations(): QuickLocation[] {
|
|
162
|
+
const home = os.homedir();
|
|
163
|
+
const locations: QuickLocation[] = [];
|
|
164
|
+
|
|
165
|
+
// Current directory
|
|
166
|
+
locations.push({
|
|
167
|
+
label: "Current Directory",
|
|
168
|
+
path: process.cwd(),
|
|
169
|
+
exists: true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Desktop
|
|
173
|
+
const desktop = path.join(home, "Desktop");
|
|
174
|
+
locations.push({
|
|
175
|
+
label: "Desktop",
|
|
176
|
+
path: desktop,
|
|
177
|
+
exists: fs.existsSync(desktop),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Documents
|
|
181
|
+
const documents = path.join(home, "Documents");
|
|
182
|
+
locations.push({
|
|
183
|
+
label: "Documents",
|
|
184
|
+
path: documents,
|
|
185
|
+
exists: fs.existsSync(documents),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Downloads
|
|
189
|
+
const downloads = path.join(home, "Downloads");
|
|
190
|
+
locations.push({
|
|
191
|
+
label: "Downloads",
|
|
192
|
+
path: downloads,
|
|
193
|
+
exists: fs.existsSync(downloads),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Last export folder (if different from above)
|
|
197
|
+
const config = readConfig();
|
|
198
|
+
if (config.lastExportFolder && fs.existsSync(config.lastExportFolder)) {
|
|
199
|
+
const isAlreadyListed = locations.some(
|
|
200
|
+
loc => path.normalize(loc.path) === path.normalize(config.lastExportFolder!)
|
|
201
|
+
);
|
|
202
|
+
if (!isAlreadyListed) {
|
|
203
|
+
locations.push({
|
|
204
|
+
label: "Recent Export",
|
|
205
|
+
path: config.lastExportFolder,
|
|
206
|
+
exists: true,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return locations.filter(loc => loc.exists);
|
|
212
|
+
}
|