@smithers-orchestrator/accounts 0.24.0 → 0.25.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/package.json +2 -2
- package/src/Account.ts +1 -1
- package/src/AccountProvider.ts +0 -1
- package/src/accountToProviderEnv.js +0 -5
- package/src/addAccount.js +29 -19
- package/src/defaultConfigDir.js +2 -14
- package/src/index.d.ts +2 -2
- package/src/index.js +7 -0
- package/src/parseAccountsFile.js +3 -2
- package/src/removeAccount.js +13 -8
- package/src/withAccountsLock.js +89 -0
- package/src/writeAccounts.js +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Manage multiple Claude/Antigravity/Codex/Gemini/Kimi subscription and API-key accounts that Smithers agents can round-robin through.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"src/"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@smithers-orchestrator/errors": "0.
|
|
23
|
+
"@smithers-orchestrator/errors": "0.25.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/src/Account.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type Account = {
|
|
|
12
12
|
provider: AccountProvider;
|
|
13
13
|
/**
|
|
14
14
|
* Absolute path to the per-account CLI config directory. Set for
|
|
15
|
-
* subscription providers (claude-code, antigravity, codex,
|
|
15
|
+
* subscription providers (claude-code, antigravity, codex, kimi).
|
|
16
16
|
*/
|
|
17
17
|
configDir?: string;
|
|
18
18
|
/**
|
package/src/AccountProvider.ts
CHANGED
|
@@ -25,11 +25,6 @@ export function accountToProviderEnv(account) {
|
|
|
25
25
|
throw new SmithersError("ACCOUNT_INVALID", `codex account "${account.label}" missing configDir`);
|
|
26
26
|
}
|
|
27
27
|
return { CODEX_HOME: account.configDir };
|
|
28
|
-
case "gemini":
|
|
29
|
-
if (!account.configDir) {
|
|
30
|
-
throw new SmithersError("ACCOUNT_INVALID", `gemini account "${account.label}" missing configDir`);
|
|
31
|
-
}
|
|
32
|
-
return { GEMINI_DIR: account.configDir };
|
|
33
28
|
case "kimi":
|
|
34
29
|
if (!account.configDir) {
|
|
35
30
|
throw new SmithersError("ACCOUNT_INVALID", `kimi account "${account.label}" missing configDir`);
|
package/src/addAccount.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
2
2
|
import { readAccounts } from "./readAccounts.js";
|
|
3
3
|
import { writeAccounts } from "./writeAccounts.js";
|
|
4
|
+
import { withAccountsLock } from "./withAccountsLock.js";
|
|
4
5
|
import { API_KEY_PROVIDERS, SUBSCRIPTION_PROVIDERS, VALID_PROVIDERS } from "./parseAccountsFile.js";
|
|
5
6
|
|
|
6
7
|
/** @typedef {import("./Account.ts").Account} Account */
|
|
@@ -22,29 +23,38 @@ export function addAccount(account, options = {}) {
|
|
|
22
23
|
if (!VALID_PROVIDERS.has(account.provider)) {
|
|
23
24
|
throw new SmithersError("ACCOUNT_INVALID", `account.provider must be one of ${[...VALID_PROVIDERS].join(", ")}, got ${JSON.stringify(account.provider)}`);
|
|
24
25
|
}
|
|
26
|
+
if (typeof account.configDir === "string" && typeof account.apiKey === "string") {
|
|
27
|
+
throw new SmithersError("ACCOUNT_INVALID", `${account.provider} account "${account.label}" must set configDir or apiKey, never both`);
|
|
28
|
+
}
|
|
25
29
|
if (SUBSCRIPTION_PROVIDERS.has(account.provider) && (!account.configDir || !account.configDir.trim())) {
|
|
26
30
|
throw new SmithersError("ACCOUNT_INVALID", `${account.provider} accounts require a non-empty configDir`);
|
|
27
31
|
}
|
|
28
32
|
if (API_KEY_PROVIDERS.has(account.provider) && typeof account.apiKey !== "string") {
|
|
29
33
|
throw new SmithersError("ACCOUNT_INVALID", `${account.provider} accounts require apiKey (may be empty string for env-var-only)`);
|
|
30
34
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
// Read-modify-write must be serialized against concurrent mutations or a
|
|
36
|
+
// second writer's atomic rename clobbers this entry (lost update). The lock
|
|
37
|
+
// covers read → conflict-check → write so the base state we mutate is the
|
|
38
|
+
// same one we persist.
|
|
39
|
+
return withAccountsLock(env, () => {
|
|
40
|
+
const existing = readAccounts(env);
|
|
41
|
+
const conflict = existing.accounts.findIndex((entry) => entry.label === account.label);
|
|
42
|
+
if (conflict >= 0 && !options.replace) {
|
|
43
|
+
throw new SmithersError("ACCOUNT_DUPLICATE_LABEL", `An account with label "${account.label}" already exists. Pass replace: true to overwrite, or use a different label.`);
|
|
44
|
+
}
|
|
45
|
+
/** @type {Account} */
|
|
46
|
+
const persisted = {
|
|
47
|
+
label: account.label,
|
|
48
|
+
provider: account.provider,
|
|
49
|
+
addedAt: account.addedAt ?? existing.accounts[conflict]?.addedAt ?? new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
if (account.configDir) persisted.configDir = account.configDir;
|
|
52
|
+
if (account.apiKey !== undefined) persisted.apiKey = account.apiKey;
|
|
53
|
+
if (account.model) persisted.model = account.model;
|
|
54
|
+
const next = conflict >= 0
|
|
55
|
+
? existing.accounts.map((entry, i) => (i === conflict ? persisted : entry))
|
|
56
|
+
: [...existing.accounts, persisted];
|
|
57
|
+
writeAccounts({ version: 1, accounts: next }, env);
|
|
58
|
+
return persisted;
|
|
59
|
+
});
|
|
50
60
|
}
|
package/src/defaultConfigDir.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { accountsRoot } from "./accountsRoot.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -26,17 +26,5 @@ export function defaultConfigDir(label, env = process.env) {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
const root = accountsRoot(env);
|
|
29
|
-
|
|
30
|
-
// Defense-in-depth: a valid label can never escape, but assert the resolved
|
|
31
|
-
// path stays under the accounts root so a future regex change can't silently
|
|
32
|
-
// reintroduce traversal.
|
|
33
|
-
const base = resolve(root, "accounts");
|
|
34
|
-
const resolved = resolve(dir);
|
|
35
|
-
if (resolved !== join(base, label) && !resolved.startsWith(base + "/")) {
|
|
36
|
-
throw new SmithersError(
|
|
37
|
-
"ACCOUNT_INVALID",
|
|
38
|
-
`Invalid account label ${JSON.stringify(label)}: resolved path escapes the accounts directory.`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
return dir;
|
|
29
|
+
return join(root, "accounts", label);
|
|
42
30
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* authenticated by a CLI config directory; API providers are authenticated by
|
|
4
4
|
* an API key.
|
|
5
5
|
*/
|
|
6
|
-
type AccountProvider = "claude-code" | "antigravity" | "codex" | "
|
|
6
|
+
type AccountProvider = "claude-code" | "antigravity" | "codex" | "kimi" | "anthropic-api" | "openai-api" | "gemini-api";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* A single registered account. Either `configDir` (subscription providers) or
|
|
@@ -17,7 +17,7 @@ type Account$1 = {
|
|
|
17
17
|
provider: AccountProvider;
|
|
18
18
|
/**
|
|
19
19
|
* Absolute path to the per-account CLI config directory. Set for
|
|
20
|
-
* subscription providers (claude-code, antigravity, codex,
|
|
20
|
+
* subscription providers (claude-code, antigravity, codex, kimi).
|
|
21
21
|
*/
|
|
22
22
|
configDir?: string;
|
|
23
23
|
/**
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./Account.ts").Account} Account */
|
|
3
|
+
/** @typedef {import("./AccountProvider.ts").AccountProvider} AccountProvider */
|
|
4
|
+
/** @typedef {import("./AccountsFile.ts").AccountsFile} AccountsFile */
|
|
5
|
+
// @smithers-type-exports-end
|
|
6
|
+
|
|
1
7
|
export { accountsRoot } from "./accountsRoot.js";
|
|
2
8
|
export { accountsFilePath } from "./accountsFilePath.js";
|
|
3
9
|
export { defaultConfigDir } from "./defaultConfigDir.js";
|
|
4
10
|
export { parseAccountsFile, SUBSCRIPTION_PROVIDERS, API_KEY_PROVIDERS, VALID_PROVIDERS } from "./parseAccountsFile.js";
|
|
5
11
|
export { readAccounts } from "./readAccounts.js";
|
|
6
12
|
export { writeAccounts } from "./writeAccounts.js";
|
|
13
|
+
export { withAccountsLock } from "./withAccountsLock.js";
|
|
7
14
|
export { listAccounts } from "./listAccounts.js";
|
|
8
15
|
export { getAccount } from "./getAccount.js";
|
|
9
16
|
export { addAccount } from "./addAccount.js";
|
package/src/parseAccountsFile.js
CHANGED
|
@@ -4,7 +4,6 @@ const VALID_PROVIDERS = new Set([
|
|
|
4
4
|
"claude-code",
|
|
5
5
|
"antigravity",
|
|
6
6
|
"codex",
|
|
7
|
-
"gemini",
|
|
8
7
|
"kimi",
|
|
9
8
|
"anthropic-api",
|
|
10
9
|
"openai-api",
|
|
@@ -15,7 +14,6 @@ const SUBSCRIPTION_PROVIDERS = new Set([
|
|
|
15
14
|
"claude-code",
|
|
16
15
|
"antigravity",
|
|
17
16
|
"codex",
|
|
18
|
-
"gemini",
|
|
19
17
|
"kimi",
|
|
20
18
|
]);
|
|
21
19
|
|
|
@@ -76,6 +74,9 @@ export function parseAccountsFile(raw) {
|
|
|
76
74
|
seenLabels.add(e.label);
|
|
77
75
|
const isSubscription = SUBSCRIPTION_PROVIDERS.has(e.provider);
|
|
78
76
|
const isApiKey = API_KEY_PROVIDERS.has(e.provider);
|
|
77
|
+
if (typeof e.configDir === "string" && typeof e.apiKey === "string") {
|
|
78
|
+
throw new SmithersError("ACCOUNTS_FILE_INVALID", `accounts.json: ${e.label} (${e.provider}) must set configDir or apiKey, never both`);
|
|
79
|
+
}
|
|
79
80
|
if (isSubscription && (typeof e.configDir !== "string" || !e.configDir.trim())) {
|
|
80
81
|
throw new SmithersError("ACCOUNTS_FILE_INVALID", `accounts.json: ${e.label} (${e.provider}) requires a non-empty configDir`);
|
|
81
82
|
}
|
package/src/removeAccount.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
2
2
|
import { readAccounts } from "./readAccounts.js";
|
|
3
3
|
import { writeAccounts } from "./writeAccounts.js";
|
|
4
|
+
import { withAccountsLock } from "./withAccountsLock.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Removes an account by label. Throws if no account exists with that label
|
|
@@ -12,12 +13,16 @@ import { writeAccounts } from "./writeAccounts.js";
|
|
|
12
13
|
*/
|
|
13
14
|
export function removeAccount(label, options = {}) {
|
|
14
15
|
const env = options.env ?? process.env;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
// Serialized read-modify-write so a concurrent addAccount cannot have its
|
|
17
|
+
// entry dropped by this remove's whole-file rewrite (lost update).
|
|
18
|
+
return withAccountsLock(env, () => {
|
|
19
|
+
const existing = readAccounts(env);
|
|
20
|
+
const next = existing.accounts.filter((entry) => entry.label !== label);
|
|
21
|
+
if (next.length === existing.accounts.length) {
|
|
22
|
+
if (options.silent) return false;
|
|
23
|
+
throw new SmithersError("ACCOUNT_NOT_FOUND", `No account with label "${label}" is registered.`);
|
|
24
|
+
}
|
|
25
|
+
writeAccounts({ version: 1, accounts: next }, env);
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
23
28
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { accountsFilePath } from "./accountsFilePath.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cross-process advisory lock around accounts.json read-modify-write. Smithers
|
|
7
|
+
* runs many agents/CLIs concurrently and both the wizard and the programmatic
|
|
8
|
+
* API can mutate ~/.smithers/accounts.json at the same time. Without a lock,
|
|
9
|
+
* two callers each readAccounts() the same base state and then writeAccounts()
|
|
10
|
+
* the whole file via atomic rename — the second rename clobbers the first
|
|
11
|
+
* writer's entry (lost update). This serializes those critical sections.
|
|
12
|
+
*
|
|
13
|
+
* The lock is an O_EXCL lock file next to accounts.json: only one process can
|
|
14
|
+
* create it. Others spin-wait briefly. A lock older than {@link STALE_LOCK_MS}
|
|
15
|
+
* is treated as orphaned (the holder crashed) and broken, so a killed process
|
|
16
|
+
* can never wedge the registry permanently — which matters because Smithers'
|
|
17
|
+
* whole premise is surviving kills/restarts.
|
|
18
|
+
*
|
|
19
|
+
* @template T
|
|
20
|
+
* @param {NodeJS.ProcessEnv} env
|
|
21
|
+
* @param {() => T} critical the read-modify-write to run while holding the lock
|
|
22
|
+
* @returns {T}
|
|
23
|
+
*/
|
|
24
|
+
export function withAccountsLock(env, critical) {
|
|
25
|
+
const lockPath = `${accountsFilePath(env)}.lock`;
|
|
26
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
27
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
28
|
+
let fd = -1;
|
|
29
|
+
for (;;) {
|
|
30
|
+
try {
|
|
31
|
+
// wx === O_CREAT | O_EXCL | O_WRONLY: atomic create-or-fail.
|
|
32
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
33
|
+
break;
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
if (cause?.code !== "EEXIST") throw cause;
|
|
36
|
+
if (Date.now() >= deadline) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Timed out acquiring accounts lock at ${lockPath} after ${LOCK_TIMEOUT_MS}ms; another process may be stuck holding it.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (breakStaleLock(lockPath)) continue;
|
|
42
|
+
spin();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
47
|
+
return critical();
|
|
48
|
+
} finally {
|
|
49
|
+
try { closeSync(fd); } catch {}
|
|
50
|
+
try { rmSync(lockPath, { force: true }); } catch {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
55
|
+
const STALE_LOCK_MS = 30_000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Removes the lock file if it is older than STALE_LOCK_MS (the holder almost
|
|
59
|
+
* certainly crashed). Returns true if the caller should retry acquiring.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} lockPath
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function breakStaleLock(lockPath) {
|
|
65
|
+
try {
|
|
66
|
+
const stat = statSync(lockPath);
|
|
67
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
68
|
+
rmSync(lockPath, { force: true });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Lock vanished between EEXIST and stat: the holder released it. Retry.
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Busy-wait a few milliseconds before retrying. addAccount/removeAccount are
|
|
80
|
+
* fully synchronous, so the critical section never yields the event loop — a
|
|
81
|
+
* short spin is simpler and safer than introducing async, and the lock is held
|
|
82
|
+
* only for a single read-modify-write.
|
|
83
|
+
*/
|
|
84
|
+
function spin() {
|
|
85
|
+
const until = Date.now() + 5;
|
|
86
|
+
while (Date.now() < until) {
|
|
87
|
+
// intentional busy-wait
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/writeAccounts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
import { accountsFilePath } from "./accountsFilePath.js";
|
|
4
5
|
|
|
@@ -6,6 +7,11 @@ import { accountsFilePath } from "./accountsFilePath.js";
|
|
|
6
7
|
* Atomically writes the accounts registry to ~/.smithers/accounts.json. The
|
|
7
8
|
* file is mode 0600 because it may contain raw API keys.
|
|
8
9
|
*
|
|
10
|
+
* Writes to a temp file then renames over the target so a crash mid-write
|
|
11
|
+
* leaves the existing accounts.json byte-identical (atomicity). If the rename
|
|
12
|
+
* fails, the temp file — which contains plaintext API keys — is removed so it
|
|
13
|
+
* cannot linger world-readable or accumulate under ~/.smithers.
|
|
14
|
+
*
|
|
9
15
|
* @param {import("./AccountsFile.ts").AccountsFile} contents
|
|
10
16
|
* @param {NodeJS.ProcessEnv} [env]
|
|
11
17
|
* @returns {string} the file path that was written
|
|
@@ -13,11 +19,19 @@ import { accountsFilePath } from "./accountsFilePath.js";
|
|
|
13
19
|
export function writeAccounts(contents, env = process.env) {
|
|
14
20
|
const path = accountsFilePath(env);
|
|
15
21
|
mkdirSync(dirname(path), { recursive: true });
|
|
16
|
-
|
|
22
|
+
// pid + time + random so two same-millisecond writers never share a temp
|
|
23
|
+
// path and clobber each other's in-flight bytes.
|
|
24
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`;
|
|
17
25
|
const serialized = `${JSON.stringify(contents, null, 2)}\n`;
|
|
18
26
|
writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
|
|
19
27
|
chmodSync(tmp, 0o600);
|
|
20
|
-
|
|
28
|
+
try {
|
|
29
|
+
renameSync(tmp, path);
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
// Don't leave a plaintext-key temp file behind on a failed rename.
|
|
32
|
+
try { rmSync(tmp, { force: true }); } catch {}
|
|
33
|
+
throw cause;
|
|
34
|
+
}
|
|
21
35
|
chmodSync(path, 0o600);
|
|
22
36
|
return path;
|
|
23
37
|
}
|