@smithers-orchestrator/accounts 0.24.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/accounts",
3
- "version": "0.24.2",
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.24.2"
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, gemini, kimi).
15
+ * subscription providers (claude-code, antigravity, codex, kimi).
16
16
  */
17
17
  configDir?: string;
18
18
  /**
@@ -7,7 +7,6 @@ export type AccountProvider =
7
7
  | "claude-code"
8
8
  | "antigravity"
9
9
  | "codex"
10
- | "gemini"
11
10
  | "kimi"
12
11
  | "anthropic-api"
13
12
  | "openai-api"
@@ -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
- const existing = readAccounts(env);
32
- const conflict = existing.accounts.findIndex((entry) => entry.label === account.label);
33
- if (conflict >= 0 && !options.replace) {
34
- throw new SmithersError("ACCOUNT_DUPLICATE_LABEL", `An account with label "${account.label}" already exists. Pass replace: true to overwrite, or use a different label.`);
35
- }
36
- /** @type {Account} */
37
- const persisted = {
38
- label: account.label,
39
- provider: account.provider,
40
- addedAt: account.addedAt ?? new Date().toISOString(),
41
- };
42
- if (account.configDir) persisted.configDir = account.configDir;
43
- if (account.apiKey !== undefined) persisted.apiKey = account.apiKey;
44
- if (account.model) persisted.model = account.model;
45
- const next = conflict >= 0
46
- ? existing.accounts.map((entry, i) => (i === conflict ? persisted : entry))
47
- : [...existing.accounts, persisted];
48
- writeAccounts({ version: 1, accounts: next }, env);
49
- return persisted;
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
  }
@@ -1,5 +1,5 @@
1
1
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
2
- import { join, resolve } from "node:path";
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
- const dir = join(root, "accounts", label);
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" | "gemini" | "kimi" | "anthropic-api" | "openai-api" | "gemini-api";
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, gemini, kimi).
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";
@@ -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
  }
@@ -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
- const existing = readAccounts(env);
16
- const next = existing.accounts.filter((entry) => entry.label !== label);
17
- if (next.length === existing.accounts.length) {
18
- if (options.silent) return false;
19
- throw new SmithersError("ACCOUNT_NOT_FOUND", `No account with label "${label}" is registered.`);
20
- }
21
- writeAccounts({ version: 1, accounts: next }, env);
22
- return true;
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
+ }
@@ -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
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
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
- renameSync(tmp, path);
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
  }