clankerbend 0.1.1 → 0.1.2
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 +15 -2
- package/apps/sticky-notes/public/app.js +35 -2
- package/apps/vim-nav/public/app.js +35 -2
- package/cli.mjs +7 -4
- package/docs/launcher-profiles.md +32 -0
- package/docs/protocol.md +15 -8
- package/host/src/app-registry.js +2 -0
- package/host/src/codex-desktop-cdp-adapter.js +23 -1
- package/host/src/codex-desktop-renderer-bridge.js +633 -1
- package/host/src/index.js +156 -4
- package/launch/accounts.mjs +360 -0
- package/launch/codex-mux-adapter.mjs +175 -0
- package/launch/profiles.mjs +30 -6
- package/launch/runtime-paths.mjs +3 -0
- package/launch/test-accounts.mjs +71 -0
- package/package.json +1 -1
- package/server.mjs +14 -7
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
|
-
|
|
977
|
-
|
|
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, "&")
|
|
1285
|
+
.replace(/"/g, """)
|
|
1286
|
+
.replace(/</g, "<");
|
|
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
|
+
}
|