clankerbend 0.1.1 → 0.1.3

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/host/src/index.js CHANGED
@@ -100,6 +100,7 @@ export class ClankerBendHost {
100
100
  this.token = this.localDevInsecure ? "" : (isNonEmptyString(options.token) ? options.token : createSessionToken());
101
101
  this.runDir = options.runDir || null;
102
102
  this.transcriptAdapter = options.transcriptAdapter || createMockTranscriptAdapter();
103
+ this.accountRegistry = options.accountRegistry || null;
103
104
  this.apps = new Map();
104
105
  this.sseClients = new Set();
105
106
  this.actionResults = new Map();
@@ -157,6 +158,15 @@ export class ClankerBendHost {
157
158
  version: null,
158
159
  error: "not started"
159
160
  },
161
+ codexAccounts: this.accountRegistry
162
+ ? {
163
+ available: true,
164
+ activeAccountId: null,
165
+ maxRunningDesktopInstances: 1,
166
+ switching: false,
167
+ ...this.accountRegistry.list()
168
+ }
169
+ : null,
160
170
  lastAction: null
161
171
  };
162
172
  }
@@ -266,6 +276,11 @@ export class ClankerBendHost {
266
276
  correlateItems: false,
267
277
  approvals: false,
268
278
  rollback: false
279
+ },
280
+ codexAccounts: {
281
+ available: Boolean(this.accountRegistry),
282
+ switch: Boolean(this.transcriptAdapter.switchTo),
283
+ adoptPrimary: Boolean(this.transcriptAdapter.adoptAsPrimary)
269
284
  }
270
285
  };
271
286
  }
@@ -323,6 +338,7 @@ export class ClankerBendHost {
323
338
  }
324
339
 
325
340
  publicState() {
341
+ if (this.accountRegistry) this.state.codexAccounts = this.accountState();
326
342
  return {
327
343
  protocolName: "clankerbend",
328
344
  protocolVersion: this.protocolVersion,
@@ -361,6 +377,7 @@ export class ClankerBendHost {
361
377
  ...this.appState(app.appId)
362
378
  })),
363
379
  appServer: this.state.appServer,
380
+ codexAccounts: this.state.codexAccounts || undefined,
364
381
  lastAction: this.state.lastAction || undefined
365
382
  };
366
383
  }
@@ -501,6 +518,19 @@ export class ClankerBendHost {
501
518
  if (url.pathname === "/clankerbend/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
502
519
  if (url.pathname === "/clankerbend/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
503
520
  if (url.pathname === "/clankerbend/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
521
+ if (url.pathname === "/clankerbend/codex/accounts" && req.method === "GET") return this.codexAccountsListEndpoint(res);
522
+ if (url.pathname === "/clankerbend/codex/accounts" && req.method === "POST") return this.codexAccountsCreateEndpoint(req, res);
523
+ if (url.pathname === "/clankerbend/codex/accounts/default" && req.method === "POST") return this.codexAccountsDefaultEndpoint(req, res);
524
+ if (url.pathname === "/clankerbend/codex/accounts/switch" && req.method === "POST") return this.codexAccountsSwitchEndpoint(req, res);
525
+ if (url.pathname === "/clankerbend/codex/primary/rollback" && req.method === "POST") return this.codexPrimaryRollbackEndpoint(req, res);
526
+ const accountRoute = this.parseCodexAccountRoute(url.pathname);
527
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/switch") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
528
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/start") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
529
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/focus") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
530
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/set-default") return this.codexAccountSetDefaultEndpoint(accountRoute.accountId, res);
531
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/adopt-as-primary") return this.codexAccountAdoptEndpoint(accountRoute.accountId, res);
532
+ if (accountRoute && req.method === "DELETE") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
533
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/delete") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
504
534
 
505
535
  const appRoute = this.parseAppRoute(url.pathname);
506
536
  if (appRoute && req.method === "GET" && appRoute.tail === "/manifest") return this.ok(res, this.appManifest(appRoute.appId));
@@ -532,6 +562,107 @@ export class ClankerBendHost {
532
562
  return { appId, tail: `/${parts.slice(4).join("/")}` };
533
563
  }
534
564
 
565
+ parseCodexAccountRoute(pathname) {
566
+ if (!pathname.startsWith("/clankerbend/codex/accounts/")) return null;
567
+ const parts = pathname.split("/");
568
+ const accountId = decodeURIComponent(parts[4] || "");
569
+ if (!accountId) return null;
570
+ return { accountId, tail: `/${parts.slice(5).join("/")}` };
571
+ }
572
+
573
+ requireAccountRegistry() {
574
+ if (!this.accountRegistry) throw httpError(404, "not_found", "Codex account registry is unavailable");
575
+ return this.accountRegistry;
576
+ }
577
+
578
+ accountState() {
579
+ if (typeof this.transcriptAdapter.accountState === "function") return this.transcriptAdapter.accountState();
580
+ return this.accountRegistry ? {
581
+ available: true,
582
+ activeAccountId: null,
583
+ maxRunningDesktopInstances: 1,
584
+ switching: false,
585
+ ...this.accountRegistry.list()
586
+ } : null;
587
+ }
588
+
589
+ codexAccountsListEndpoint(res) {
590
+ this.requireAccountRegistry();
591
+ this.state.codexAccounts = this.accountState();
592
+ return this.ok(res, this.state.codexAccounts);
593
+ }
594
+
595
+ async codexAccountsCreateEndpoint(req, res) {
596
+ this.requireAccountRegistry();
597
+ const body = await readJsonObject(req, "account create request body");
598
+ const result = typeof this.transcriptAdapter.createAccount === "function"
599
+ ? await this.transcriptAdapter.createAccount(body)
600
+ : this.accountRegistry.createManaged(body);
601
+ this.state.codexAccounts = this.accountState();
602
+ return this.ok(res, result, 201);
603
+ }
604
+
605
+ async codexAccountsDefaultEndpoint(req, res) {
606
+ const body = await readJsonObject(req, "account default request body");
607
+ if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
608
+ return this.codexAccountSetDefaultEndpoint(body.accountId, res);
609
+ }
610
+
611
+ async codexAccountsSwitchEndpoint(req, res) {
612
+ const body = await readJsonObject(req, "account switch request body");
613
+ if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
614
+ return this.codexAccountSwitchEndpoint(body.accountId, res);
615
+ }
616
+
617
+ async codexAccountSwitchEndpoint(accountId, res) {
618
+ this.requireAccountRegistry();
619
+ if (typeof this.transcriptAdapter.switchTo !== "function") {
620
+ throw httpError(409, "unsupported", "active adapter does not support Codex account switching");
621
+ }
622
+ const result = await this.transcriptAdapter.switchTo(accountId);
623
+ this.state.codexAccounts = this.accountState();
624
+ return this.ok(res, result);
625
+ }
626
+
627
+ async codexAccountSetDefaultEndpoint(accountId, res) {
628
+ this.requireAccountRegistry();
629
+ const result = typeof this.transcriptAdapter.setDefault === "function"
630
+ ? await this.transcriptAdapter.setDefault(accountId)
631
+ : this.accountRegistry.setDefault(accountId);
632
+ this.state.codexAccounts = this.accountState();
633
+ return this.ok(res, result);
634
+ }
635
+
636
+ async codexAccountAdoptEndpoint(accountId, res) {
637
+ this.requireAccountRegistry();
638
+ if (typeof this.transcriptAdapter.adoptAsPrimary !== "function") {
639
+ throw httpError(409, "unsupported", "active adapter does not support primary adoption");
640
+ }
641
+ const result = await this.transcriptAdapter.adoptAsPrimary(accountId);
642
+ this.state.codexAccounts = this.accountState();
643
+ return this.ok(res, result);
644
+ }
645
+
646
+ async codexPrimaryRollbackEndpoint(req, res) {
647
+ this.requireAccountRegistry();
648
+ const body = await readJsonObject(req, "primary rollback request body");
649
+ if (typeof this.transcriptAdapter.rollbackPrimary !== "function") {
650
+ throw httpError(409, "unsupported", "active adapter does not support primary rollback");
651
+ }
652
+ const result = await this.transcriptAdapter.rollbackPrimary(body);
653
+ this.state.codexAccounts = this.accountState();
654
+ return this.ok(res, result);
655
+ }
656
+
657
+ async codexAccountDeleteEndpoint(accountId, res) {
658
+ this.requireAccountRegistry();
659
+ const result = typeof this.transcriptAdapter.deleteAccount === "function"
660
+ ? await this.transcriptAdapter.deleteAccount(accountId)
661
+ : this.accountRegistry.deleteManaged(accountId);
662
+ this.state.codexAccounts = this.accountState();
663
+ return this.ok(res, result);
664
+ }
665
+
535
666
  async actionEndpoint(appId, req, res) {
536
667
  const app = this.requireApp(appId);
537
668
  const body = await readJsonObject(req, "action request body");
@@ -973,10 +1104,17 @@ export class ClankerBendHost {
973
1104
  res.writeHead(404).end("not found");
974
1105
  return;
975
1106
  }
976
- res.writeHead(200, {
977
- "content-type": mimeType(filePath),
1107
+ const contentType = mimeType(filePath);
1108
+ const headers = {
1109
+ "content-type": contentType,
978
1110
  "cache-control": "no-store"
979
- });
1111
+ };
1112
+ if (this.token && contentType.startsWith("text/html")) {
1113
+ res.writeHead(200, headers);
1114
+ res.end(injectAppToken(readFileSync(filePath, "utf8"), this.token));
1115
+ return;
1116
+ }
1117
+ res.writeHead(200, headers);
980
1118
  createReadStream(filePath).pipe(res);
981
1119
  }
982
1120
 
@@ -1023,7 +1161,7 @@ export class ClankerBendHost {
1023
1161
 
1024
1162
  applyCors(res) {
1025
1163
  res.setHeader("access-control-allow-origin", "*");
1026
- res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
1164
+ res.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
1027
1165
  res.setHeader("access-control-allow-headers", "authorization,content-type");
1028
1166
  }
1029
1167
  }
@@ -1134,6 +1272,20 @@ export function createSessionToken() {
1134
1272
  return randomBytes(32).toString("base64url");
1135
1273
  }
1136
1274
 
1275
+ function injectAppToken(html, token) {
1276
+ const script = `<meta name="clankerbend-token" content="${escapeHtmlAttribute(token)}">\n <script>window.__CLANKERBEND_TOKEN=${JSON.stringify(token)};</script>`;
1277
+ return /<head[^>]*>/i.test(html)
1278
+ ? html.replace(/<head[^>]*>/i, (match) => `${match}\n ${script}`)
1279
+ : `${script}\n${html}`;
1280
+ }
1281
+
1282
+ function escapeHtmlAttribute(value) {
1283
+ return String(value)
1284
+ .replace(/&/g, "&amp;")
1285
+ .replace(/"/g, "&quot;")
1286
+ .replace(/</g, "&lt;");
1287
+ }
1288
+
1137
1289
  function requirePermission(app, permission) {
1138
1290
  if (app.permissions?.[permission] === false) {
1139
1291
  throw httpError(403, "forbidden", `${app.appId} lacks ${permission}`);
@@ -0,0 +1,360 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export const ACCOUNT_REGISTRY_VERSION = 1;
6
+ export const PRIMARY_ACCOUNT_ID = "primary";
7
+ export const MAX_ACCOUNT_PROFILES = 20;
8
+
9
+ export function primaryCodexHome() {
10
+ return process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), ".codex");
11
+ }
12
+
13
+ export class CodexAccountRegistry {
14
+ constructor(options = {}) {
15
+ if (!options.root) throw new Error("account registry root is required");
16
+ if (!options.primaryElectronProfile) throw new Error("primary electron profile is required");
17
+ this.root = resolve(options.root);
18
+ this.registryPath = options.registryPath || join(this.root, "accounts.json");
19
+ this.accountsDir = options.accountsDir || join(this.root, "accounts");
20
+ this.deletedDir = options.deletedDir || join(this.root, "deleted-accounts");
21
+ this.primaryCodexHome = resolve(options.primaryCodexHome || primaryCodexHome());
22
+ this.primaryElectronProfile = resolve(options.primaryElectronProfile);
23
+ this.maxAccounts = options.maxAccounts || MAX_ACCOUNT_PROFILES;
24
+ this.registry = this.load();
25
+ }
26
+
27
+ list() {
28
+ this.ensurePrimary();
29
+ return {
30
+ version: ACCOUNT_REGISTRY_VERSION,
31
+ clankerbendDefaultAccountId: this.registry.clankerbendDefaultAccountId || PRIMARY_ACCOUNT_ID,
32
+ maxAccounts: this.maxAccounts,
33
+ accounts: this.registry.accounts.map((account) => this.publicAccount(account)),
34
+ backups: this.registry.backups || [],
35
+ deletedAccounts: this.registry.deletedAccounts || []
36
+ };
37
+ }
38
+
39
+ get(id) {
40
+ this.ensurePrimary();
41
+ const account = this.registry.accounts.find((candidate) => candidate.id === id);
42
+ if (!account) throw new Error(`unknown Codex account profile: ${id}`);
43
+ return account;
44
+ }
45
+
46
+ getDefault() {
47
+ const id = this.registry.clankerbendDefaultAccountId || PRIMARY_ACCOUNT_ID;
48
+ return this.registry.accounts.find((account) => account.id === id) || this.get(PRIMARY_ACCOUNT_ID);
49
+ }
50
+
51
+ createManaged(input = {}) {
52
+ this.ensurePrimary();
53
+ if (this.registry.accounts.length >= this.maxAccounts) {
54
+ throw new Error(`Codex account profile limit reached (${this.maxAccounts})`);
55
+ }
56
+ const id = input.id
57
+ ? safeAccountId(input.id)
58
+ : uniqueAccountId(this.registry.accounts, accountIdFromLabel(input.label || "account"));
59
+ if (input.id && this.registry.accounts.some((account) => account.id === id)) {
60
+ throw new Error(`Codex account profile already exists: ${id}`);
61
+ }
62
+ const accountRoot = join(this.accountsDir, id);
63
+ const codexHome = join(accountRoot, "codex-home");
64
+ const electronProfile = join(accountRoot, "electron-profile");
65
+ mkdirSync(codexHome, { recursive: true, mode: 0o700 });
66
+ mkdirSync(electronProfile, { recursive: true, mode: 0o700 });
67
+ writeManagedConfig(codexHome, this.primaryCodexHome);
68
+ const account = {
69
+ id,
70
+ kind: "managed",
71
+ label: String(input.label || id),
72
+ codexHome,
73
+ electronProfile,
74
+ createdAt: new Date().toISOString(),
75
+ createdFrom: PRIMARY_ACCOUNT_ID
76
+ };
77
+ this.registry.accounts.push(account);
78
+ this.save();
79
+ return this.publicAccount(account);
80
+ }
81
+
82
+ setDefault(id) {
83
+ this.get(id);
84
+ this.registry.clankerbendDefaultAccountId = id;
85
+ this.save();
86
+ return this.list();
87
+ }
88
+
89
+ adoptAsPrimary(id) {
90
+ const target = this.get(id);
91
+ if (target.kind === "primary") throw new Error("primary account is already the primary Codex home");
92
+ const timestamp = timestampSegment();
93
+ const primary = this.get(PRIMARY_ACCOUNT_ID);
94
+ const oldPrimaryId = uniqueAccountId(this.registry.accounts, `previous-primary-${timestamp}`);
95
+ const backupCodexHome = `${this.primaryCodexHome}.backup_${timestamp}`;
96
+ const oldPrimaryElectronProfile = join(this.accountsDir, oldPrimaryId, "electron-profile");
97
+ const rollbackBackup = {
98
+ id: oldPrimaryId,
99
+ label: `Previous Primary ${timestamp}`,
100
+ codexHome: backupCodexHome,
101
+ createdAt: new Date().toISOString(),
102
+ reason: "adopt-as-primary"
103
+ };
104
+
105
+ if (!existsSync(target.codexHome)) throw new Error(`target CODEX_HOME does not exist: ${target.codexHome}`);
106
+ if (existsSync(backupCodexHome)) throw new Error(`backup path already exists: ${backupCodexHome}`);
107
+
108
+ if (existsSync(this.primaryCodexHome)) movePath(this.primaryCodexHome, backupCodexHome);
109
+ if (existsSync(primary.electronProfile)) movePath(primary.electronProfile, oldPrimaryElectronProfile);
110
+ movePath(target.codexHome, this.primaryCodexHome);
111
+ if (existsSync(target.electronProfile)) movePath(target.electronProfile, primary.electronProfile);
112
+ else mkdirSync(primary.electronProfile, { recursive: true, mode: 0o700 });
113
+
114
+ const oldPrimary = {
115
+ id: oldPrimaryId,
116
+ kind: "managed",
117
+ label: rollbackBackup.label,
118
+ codexHome: backupCodexHome,
119
+ electronProfile: oldPrimaryElectronProfile,
120
+ createdAt: rollbackBackup.createdAt,
121
+ createdFrom: PRIMARY_ACCOUNT_ID,
122
+ backup: true
123
+ };
124
+ primary.lastAdoptedFrom = target.id;
125
+ primary.lastAdoptedAt = new Date().toISOString();
126
+ primary.codexHome = this.primaryCodexHome;
127
+ primary.electronProfile = this.primaryElectronProfile;
128
+ this.registry.accounts = this.registry.accounts
129
+ .filter((account) => account.id !== target.id)
130
+ .map((account) => account.id === PRIMARY_ACCOUNT_ID ? primary : account);
131
+ this.registry.accounts.push(oldPrimary);
132
+ this.registry.backups = [...(this.registry.backups || []), rollbackBackup];
133
+ this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
134
+ this.save();
135
+ return {
136
+ adoptedAccountId: id,
137
+ primary: this.publicAccount(primary),
138
+ previousPrimary: this.publicAccount(oldPrimary),
139
+ backup: rollbackBackup
140
+ };
141
+ }
142
+
143
+ rollbackPrimary(input = {}) {
144
+ const backupId = input.backupId || input.accountId;
145
+ if (!backupId) throw new Error("backupId is required");
146
+ const backupAccount = this.get(backupId);
147
+ if (backupAccount.kind === "primary") throw new Error("cannot rollback from primary account");
148
+ if (!existsSync(backupAccount.codexHome)) throw new Error(`backup CODEX_HOME does not exist: ${backupAccount.codexHome}`);
149
+
150
+ const timestamp = timestampSegment();
151
+ const primary = this.get(PRIMARY_ACCOUNT_ID);
152
+ const replacedId = uniqueAccountId(this.registry.accounts, `rollback-replaced-${timestamp}`);
153
+ const replacedCodexHome = `${this.primaryCodexHome}.rollback_replaced_${timestamp}`;
154
+ const replacedElectronProfile = join(this.accountsDir, replacedId, "electron-profile");
155
+
156
+ if (existsSync(this.primaryCodexHome)) movePath(this.primaryCodexHome, replacedCodexHome);
157
+ if (existsSync(primary.electronProfile)) movePath(primary.electronProfile, replacedElectronProfile);
158
+ movePath(backupAccount.codexHome, this.primaryCodexHome);
159
+ if (existsSync(backupAccount.electronProfile)) movePath(backupAccount.electronProfile, primary.electronProfile);
160
+ else mkdirSync(primary.electronProfile, { recursive: true, mode: 0o700 });
161
+
162
+ const replacedAccount = {
163
+ id: replacedId,
164
+ kind: "managed",
165
+ label: `Rollback Replaced ${timestamp}`,
166
+ codexHome: replacedCodexHome,
167
+ electronProfile: replacedElectronProfile,
168
+ createdAt: new Date().toISOString(),
169
+ createdFrom: PRIMARY_ACCOUNT_ID,
170
+ backup: true
171
+ };
172
+ primary.lastRollbackFrom = backupAccount.id;
173
+ primary.lastRollbackAt = new Date().toISOString();
174
+ primary.codexHome = this.primaryCodexHome;
175
+ primary.electronProfile = this.primaryElectronProfile;
176
+ this.registry.accounts = this.registry.accounts
177
+ .filter((account) => account.id !== backupAccount.id)
178
+ .map((account) => account.id === PRIMARY_ACCOUNT_ID ? primary : account);
179
+ this.registry.accounts.push(replacedAccount);
180
+ this.registry.backups = [...(this.registry.backups || []), {
181
+ id: replacedId,
182
+ label: replacedAccount.label,
183
+ codexHome: replacedCodexHome,
184
+ createdAt: replacedAccount.createdAt,
185
+ reason: "rollback-replaced-primary"
186
+ }];
187
+ this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
188
+ this.save();
189
+ return {
190
+ restoredAccountId: backupAccount.id,
191
+ primary: this.publicAccount(primary),
192
+ replacedPrimary: this.publicAccount(replacedAccount)
193
+ };
194
+ }
195
+
196
+ deleteManaged(id) {
197
+ const account = this.get(id);
198
+ if (account.kind === "primary") throw new Error("primary account cannot be deleted");
199
+ const timestamp = timestampSegment();
200
+ const deleteRoot = join(this.deletedDir, `${id}_${timestamp}`);
201
+ mkdirSync(deleteRoot, { recursive: true, mode: 0o700 });
202
+ const deleted = {
203
+ id,
204
+ label: account.label,
205
+ deletedAt: new Date().toISOString(),
206
+ originalCodexHome: account.codexHome,
207
+ originalElectronProfile: account.electronProfile,
208
+ deletedRoot: deleteRoot,
209
+ codexHome: null,
210
+ electronProfile: null
211
+ };
212
+ if (existsSync(account.codexHome)) {
213
+ const destination = join(deleteRoot, "codex-home");
214
+ movePath(account.codexHome, destination);
215
+ deleted.codexHome = destination;
216
+ }
217
+ if (existsSync(account.electronProfile)) {
218
+ const destination = join(deleteRoot, "electron-profile");
219
+ movePath(account.electronProfile, destination);
220
+ deleted.electronProfile = destination;
221
+ }
222
+ this.registry.accounts = this.registry.accounts.filter((candidate) => candidate.id !== id);
223
+ if (this.registry.clankerbendDefaultAccountId === id) this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
224
+ this.registry.deletedAccounts = [...(this.registry.deletedAccounts || []), deleted];
225
+ this.save();
226
+ return deleted;
227
+ }
228
+
229
+ load() {
230
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
231
+ mkdirSync(this.accountsDir, { recursive: true, mode: 0o700 });
232
+ let registry = null;
233
+ if (existsSync(this.registryPath)) {
234
+ registry = JSON.parse(readFileSync(this.registryPath, "utf8"));
235
+ }
236
+ registry ||= {
237
+ version: ACCOUNT_REGISTRY_VERSION,
238
+ clankerbendDefaultAccountId: PRIMARY_ACCOUNT_ID,
239
+ accounts: [],
240
+ backups: [],
241
+ deletedAccounts: []
242
+ };
243
+ registry.version = ACCOUNT_REGISTRY_VERSION;
244
+ registry.accounts = Array.isArray(registry.accounts) ? registry.accounts : [];
245
+ registry.backups = Array.isArray(registry.backups) ? registry.backups : [];
246
+ registry.deletedAccounts = Array.isArray(registry.deletedAccounts) ? registry.deletedAccounts : [];
247
+ this.registry = registry;
248
+ this.ensurePrimary();
249
+ return this.registry;
250
+ }
251
+
252
+ ensurePrimary() {
253
+ const primary = this.registry.accounts.find((account) => account.id === PRIMARY_ACCOUNT_ID);
254
+ if (primary) {
255
+ primary.kind = "primary";
256
+ primary.label ||= "Primary";
257
+ primary.codexHome = this.primaryCodexHome;
258
+ primary.electronProfile = this.primaryElectronProfile;
259
+ return primary;
260
+ }
261
+ const account = {
262
+ id: PRIMARY_ACCOUNT_ID,
263
+ kind: "primary",
264
+ label: "Primary",
265
+ codexHome: this.primaryCodexHome,
266
+ electronProfile: this.primaryElectronProfile,
267
+ createdAt: new Date().toISOString()
268
+ };
269
+ this.registry.accounts.unshift(account);
270
+ this.registry.clankerbendDefaultAccountId ||= PRIMARY_ACCOUNT_ID;
271
+ this.save();
272
+ return account;
273
+ }
274
+
275
+ save() {
276
+ mkdirSync(dirname(this.registryPath), { recursive: true, mode: 0o700 });
277
+ writeFileSync(this.registryPath, `${JSON.stringify(this.registry, null, 2)}\n`, { mode: 0o600 });
278
+ }
279
+
280
+ publicAccount(account) {
281
+ return {
282
+ id: account.id,
283
+ kind: account.kind,
284
+ label: account.label,
285
+ codexHome: account.codexHome,
286
+ electronProfile: account.electronProfile,
287
+ auth: {
288
+ authJson: existsSync(join(account.codexHome, "auth.json")),
289
+ configToml: existsSync(join(account.codexHome, "config.toml"))
290
+ },
291
+ createdAt: account.createdAt,
292
+ createdFrom: account.createdFrom,
293
+ backup: account.backup || undefined,
294
+ lastSeenAccount: account.lastSeenAccount || undefined,
295
+ lastAdoptedFrom: account.lastAdoptedFrom || undefined,
296
+ lastAdoptedAt: account.lastAdoptedAt || undefined,
297
+ lastRollbackFrom: account.lastRollbackFrom || undefined,
298
+ lastRollbackAt: account.lastRollbackAt || undefined
299
+ };
300
+ }
301
+ }
302
+
303
+ function writeManagedConfig(codexHome, primaryHome) {
304
+ const primaryConfig = join(primaryHome, "config.toml");
305
+ let content = "";
306
+ if (existsSync(primaryConfig)) {
307
+ const match = readFileSync(primaryConfig, "utf8").match(/^\s*cli_auth_credentials_store\s*=.*$/m);
308
+ if (match) content = `${match[0].trim()}\n`;
309
+ }
310
+ if (!content && existsSync(join(primaryHome, "auth.json"))) {
311
+ content = "cli_auth_credentials_store = \"file\"\n";
312
+ }
313
+ if (content) writeFileSync(join(codexHome, "config.toml"), content, { mode: 0o600 });
314
+ }
315
+
316
+ function accountIdFromLabel(label) {
317
+ const id = String(label || "account")
318
+ .trim()
319
+ .toLowerCase()
320
+ .replace(/[^a-z0-9_-]+/g, "-")
321
+ .replace(/^-+|-+$/g, "")
322
+ .slice(0, 40);
323
+ if (!id) return "account";
324
+ return /^[a-z]/.test(id) ? id : `account-${id}`.slice(0, 40);
325
+ }
326
+
327
+ function safeAccountId(id) {
328
+ const value = String(id || "").trim();
329
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(value)) {
330
+ throw new Error("account id must start with a lowercase letter and contain only lowercase letters, numbers, _ or -");
331
+ }
332
+ return value;
333
+ }
334
+
335
+ function uniqueAccountId(accounts, base) {
336
+ let candidate = safeAccountId(base);
337
+ let suffix = 2;
338
+ while (accounts.some((account) => account.id === candidate)) {
339
+ candidate = safeAccountId(`${base}-${suffix}`);
340
+ suffix += 1;
341
+ }
342
+ return candidate;
343
+ }
344
+
345
+ function timestampSegment(date = new Date()) {
346
+ return date.toISOString().replace(/\..*$/, "").replace("T", "_").replace(/:/g, "");
347
+ }
348
+
349
+ function movePath(from, to) {
350
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
351
+ try {
352
+ renameSync(from, to);
353
+ } catch (err) {
354
+ if (err?.code !== "EXDEV") throw err;
355
+ const stat = statSync(from);
356
+ if (stat.isDirectory()) cpSync(from, to, { recursive: true, force: false });
357
+ else cpSync(from, to, { force: false });
358
+ rmSync(from, { recursive: true, force: true });
359
+ }
360
+ }