clankerbend 0.1.0 → 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/DEVELOPERS.md +6 -6
- package/README.md +31 -14
- package/apps/README.md +3 -3
- package/apps/sticky-notes/README.md +2 -2
- package/apps/sticky-notes/{onewhack.manifest.json → clankerbend.manifest.json} +1 -1
- package/apps/sticky-notes/package.json +3 -3
- package/apps/sticky-notes/public/app.js +42 -9
- package/apps/sticky-notes/src/sticky-notes-app.js +17 -21
- package/apps/vim-nav/README.md +22 -22
- package/apps/vim-nav/{onewhack.manifest.json → clankerbend.manifest.json} +4 -4
- package/apps/vim-nav/package.json +3 -3
- package/apps/vim-nav/public/app.js +44 -11
- package/apps/vim-nav/public/index.html +2 -2
- package/apps/vim-nav/server.mjs +2 -2
- package/apps/vim-nav/src/vim-nav-app.js +5 -5
- package/assets/clankerbend-banner.webp +0 -0
- package/assets/clankerbend-demo-thumbnail.webp +0 -0
- package/assets/clankerbend.svg +26 -0
- package/cli.mjs +22 -11
- package/docs/app-lifecycle.md +9 -9
- package/docs/app-manifest.md +4 -4
- package/docs/author-guide.md +5 -5
- package/docs/launcher-profiles.md +45 -10
- package/docs/protocol.md +248 -241
- package/host/README.md +4 -4
- package/host/src/app-registry.js +8 -6
- package/host/src/codex-desktop-cdp-adapter.js +85 -65
- package/host/src/codex-desktop-renderer-bridge.js +839 -270
- package/host/src/index.js +186 -34
- package/launch/accounts.mjs +360 -0
- package/launch/codex-mux-adapter.mjs +175 -0
- package/launch/profiles.mjs +40 -16
- package/launch/runtime-paths.mjs +9 -6
- package/launch/test-accounts.mjs +71 -0
- package/package.json +12 -10
- package/scripts/release-npm.mjs +1 -2
- package/server.mjs +21 -14
- package/assets/onewhack.jpg +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createCodexDesktopCdpAdapter } from "../host/src/codex-desktop-cdp-adapter.js";
|
|
2
|
+
import { PRIMARY_ACCOUNT_ID } from "./accounts.mjs";
|
|
3
|
+
|
|
4
|
+
export function createCodexDesktopMuxAdapter(options = {}) {
|
|
5
|
+
return new CodexDesktopMuxAdapter(options);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class CodexDesktopMuxAdapter {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
if (!options.accountRegistry) throw new Error("accountRegistry is required");
|
|
11
|
+
this.name = "codex-desktop-mux";
|
|
12
|
+
this.cdp = true;
|
|
13
|
+
this.rendererInjection = true;
|
|
14
|
+
this.rendererFetchToLoopback = false;
|
|
15
|
+
this.accountRegistry = options.accountRegistry;
|
|
16
|
+
this.adapterOptions = options.adapterOptions || {};
|
|
17
|
+
this.providers = options.providers || undefined;
|
|
18
|
+
this.startAccountId = options.startAccountId || null;
|
|
19
|
+
this.host = null;
|
|
20
|
+
this.activeAccountId = null;
|
|
21
|
+
this.activeAdapter = null;
|
|
22
|
+
this.switching = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async start(host) {
|
|
26
|
+
this.host = host;
|
|
27
|
+
const account = this.startAccountId ? this.accountRegistry.get(this.startAccountId) : this.accountRegistry.getDefault();
|
|
28
|
+
await this.switchTo(account.id, { initial: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async stop() {
|
|
32
|
+
await this.stopActive();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async switchTo(accountId, options = {}) {
|
|
36
|
+
if (this.switching) throw new Error("Codex account switch already in progress");
|
|
37
|
+
this.switching = true;
|
|
38
|
+
try {
|
|
39
|
+
const account = this.accountRegistry.get(accountId);
|
|
40
|
+
if (!options.initial) await this.stopActive();
|
|
41
|
+
this.activeAccountId = account.id;
|
|
42
|
+
this.host.state.codexAccounts = this.accountState();
|
|
43
|
+
this.host.setDesktopStatus({
|
|
44
|
+
cdpStatus: "starting",
|
|
45
|
+
cdpPort: null,
|
|
46
|
+
desktopPid: null,
|
|
47
|
+
target: null,
|
|
48
|
+
error: null
|
|
49
|
+
});
|
|
50
|
+
this.host.updateTranscript({
|
|
51
|
+
anchors: [],
|
|
52
|
+
visibleCount: 0,
|
|
53
|
+
annotationCount: 0,
|
|
54
|
+
scroll: null,
|
|
55
|
+
updatedAt: new Date().toISOString()
|
|
56
|
+
}, { broadcast: false });
|
|
57
|
+
this.activeAdapter = createCodexDesktopCdpAdapter({
|
|
58
|
+
...this.adapterOptions,
|
|
59
|
+
profileDir: account.electronProfile,
|
|
60
|
+
codexHome: account.codexHome,
|
|
61
|
+
resetProfileDir: false
|
|
62
|
+
});
|
|
63
|
+
await this.activeAdapter.start(this.host);
|
|
64
|
+
this.providers = this.activeAdapter.providers;
|
|
65
|
+
return {
|
|
66
|
+
switched: true,
|
|
67
|
+
activeAccountId: account.id,
|
|
68
|
+
account: this.accountRegistry.publicAccount(account)
|
|
69
|
+
};
|
|
70
|
+
} finally {
|
|
71
|
+
this.switching = false;
|
|
72
|
+
if (this.host) {
|
|
73
|
+
this.host.state.codexAccounts = this.accountState();
|
|
74
|
+
this.host.touchAndBroadcast();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async stopActive() {
|
|
80
|
+
const adapter = this.activeAdapter;
|
|
81
|
+
this.activeAdapter = null;
|
|
82
|
+
if (adapter) await adapter.stop?.();
|
|
83
|
+
if (this.host) {
|
|
84
|
+
this.host.setDesktopStatus({
|
|
85
|
+
cdpStatus: "exited",
|
|
86
|
+
cdpPort: null,
|
|
87
|
+
desktopPid: null,
|
|
88
|
+
target: null,
|
|
89
|
+
error: null
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
accountState() {
|
|
95
|
+
return {
|
|
96
|
+
available: true,
|
|
97
|
+
activeAccountId: this.activeAccountId,
|
|
98
|
+
maxRunningDesktopInstances: 1,
|
|
99
|
+
switching: this.switching,
|
|
100
|
+
...this.accountRegistry.list()
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async createAccount(input = {}) {
|
|
105
|
+
const account = this.accountRegistry.createManaged(input);
|
|
106
|
+
this.host.state.codexAccounts = this.accountState();
|
|
107
|
+
this.host.touchAndBroadcast();
|
|
108
|
+
return account;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async setDefault(accountId) {
|
|
112
|
+
const result = this.accountRegistry.setDefault(accountId);
|
|
113
|
+
this.host.state.codexAccounts = this.accountState();
|
|
114
|
+
this.host.touchAndBroadcast();
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async adoptAsPrimary(accountId) {
|
|
119
|
+
await this.stopActive();
|
|
120
|
+
const result = this.accountRegistry.adoptAsPrimary(accountId);
|
|
121
|
+
const launched = await this.switchTo(PRIMARY_ACCOUNT_ID);
|
|
122
|
+
return { ...result, launched };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async rollbackPrimary(input = {}) {
|
|
126
|
+
await this.stopActive();
|
|
127
|
+
const result = this.accountRegistry.rollbackPrimary(input);
|
|
128
|
+
const launched = await this.switchTo(PRIMARY_ACCOUNT_ID);
|
|
129
|
+
return { ...result, launched };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async deleteAccount(accountId) {
|
|
133
|
+
const account = this.accountRegistry.get(accountId);
|
|
134
|
+
if (account.kind === "primary") throw new Error("primary account cannot be deleted");
|
|
135
|
+
if (accountId === this.activeAccountId) await this.stopActive();
|
|
136
|
+
const result = this.accountRegistry.deleteManaged(accountId);
|
|
137
|
+
if (accountId === this.activeAccountId) this.activeAccountId = null;
|
|
138
|
+
this.host.state.codexAccounts = this.accountState();
|
|
139
|
+
this.host.touchAndBroadcast();
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
active() {
|
|
144
|
+
if (!this.activeAdapter) throw new Error("Codex Desktop is not running");
|
|
145
|
+
return this.activeAdapter;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
openPanel(...args) {
|
|
149
|
+
return this.active().openPanel(...args);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
scrollToAnchor(...args) {
|
|
153
|
+
return this.active().scrollToAnchor(...args);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
highlightAnchor(...args) {
|
|
157
|
+
return this.active().highlightAnchor(...args);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
highlightRange(...args) {
|
|
161
|
+
return this.active().highlightRange(...args);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setComposerDraft(...args) {
|
|
165
|
+
return this.active().setComposerDraft(...args);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
submitComposer(...args) {
|
|
169
|
+
return this.active().submitComposer(...args);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
attachFiles(...args) {
|
|
173
|
+
return this.active().attachFiles(...args);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/launch/profiles.mjs
CHANGED
|
@@ -8,35 +8,54 @@ import {
|
|
|
8
8
|
providersFromRendererBridges,
|
|
9
9
|
readAppManifest
|
|
10
10
|
} from "../host/src/app-registry.js";
|
|
11
|
-
import {
|
|
11
|
+
import { CodexAccountRegistry, primaryCodexHome } from "./accounts.mjs";
|
|
12
|
+
import { clankerbendRuntimePaths } from "./runtime-paths.mjs";
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const ROOT_DIR = join(__dirname, "..");
|
|
15
16
|
|
|
16
17
|
export const PROFILE_NAVIGATE = "navigate";
|
|
17
|
-
export const VIM_NAV_MANIFEST_PATH = join(ROOT_DIR, "apps/vim-nav/
|
|
18
|
-
export const STICKY_NOTES_MANIFEST_PATH = join(ROOT_DIR, "apps/sticky-notes/
|
|
18
|
+
export const VIM_NAV_MANIFEST_PATH = join(ROOT_DIR, "apps/vim-nav/clankerbend.manifest.json");
|
|
19
|
+
export const STICKY_NOTES_MANIFEST_PATH = join(ROOT_DIR, "apps/sticky-notes/clankerbend.manifest.json");
|
|
19
20
|
export const CODEX_DESKTOP_RENDERER_BRIDGE_PATH = join(ROOT_DIR, "host/src/codex-desktop-renderer-bridge.js");
|
|
20
21
|
export const VIM_NAV_APP_ID = readAppManifest(VIM_NAV_MANIFEST_PATH).appId;
|
|
21
22
|
export const STICKY_NOTES_APP_ID = readAppManifest(STICKY_NOTES_MANIFEST_PATH).appId;
|
|
22
23
|
|
|
23
24
|
export async function navigateProfile(options = {}) {
|
|
24
|
-
const runtimePaths =
|
|
25
|
+
const runtimePaths = clankerbendRuntimePaths({ stateDir: options.stateDir });
|
|
25
26
|
const runDir = options.runDir || runtimePaths.runDir;
|
|
27
|
+
const registryProfileId = options.registryProfileId || process.env.ONEWILL_CLANKERBEND_PROFILE || "default";
|
|
28
|
+
const primaryElectronProfile = options.runDir ? join(runDir, "codex-profile") : runtimePaths.codexProfileDir;
|
|
29
|
+
const accountStateRoot = options.runDir && !options.stateDir ? runDir : runtimePaths.root;
|
|
30
|
+
const accountRegistry = new CodexAccountRegistry({
|
|
31
|
+
root: accountStateRoot,
|
|
32
|
+
registryPath: join(accountStateRoot, "accounts.json"),
|
|
33
|
+
accountsDir: join(accountStateRoot, "accounts"),
|
|
34
|
+
deletedDir: join(accountStateRoot, "deleted-accounts"),
|
|
35
|
+
primaryCodexHome: options.primaryCodexHome || primaryCodexHome(),
|
|
36
|
+
primaryElectronProfile
|
|
37
|
+
});
|
|
38
|
+
const codexAccount = options.accountId ? accountRegistry.get(options.accountId) : accountRegistry.getDefault();
|
|
26
39
|
const profile = await loadProfileFromManifests({
|
|
27
40
|
profileId: PROFILE_NAVIGATE,
|
|
28
41
|
name: "Navigate",
|
|
29
42
|
description: "Codex Desktop with OneWill Navigate transcript controls.",
|
|
30
|
-
hostId: "onewill.
|
|
31
|
-
hostName: "
|
|
43
|
+
hostId: "onewill.clankerbend.host",
|
|
44
|
+
hostName: "ClankerBend Navigate",
|
|
32
45
|
runDir,
|
|
33
46
|
runtimePaths,
|
|
47
|
+
accountRegistry,
|
|
34
48
|
defaultPanelAppId: VIM_NAV_APP_ID,
|
|
35
49
|
manifestPaths: configuredManifestPaths([VIM_NAV_MANIFEST_PATH, STICKY_NOTES_MANIFEST_PATH], {
|
|
36
50
|
registryConfigPath: options.registryConfigPath || runtimePaths.registryConfigPath,
|
|
37
|
-
registryProfileId
|
|
51
|
+
registryProfileId
|
|
38
52
|
})
|
|
39
53
|
});
|
|
54
|
+
profile.launchProfileId = registryProfileId;
|
|
55
|
+
profile.launchProfileName = profileNameFromId(registryProfileId);
|
|
56
|
+
profile.accountRegistry = accountRegistry;
|
|
57
|
+
profile.codexAccount = codexAccount;
|
|
58
|
+
profile.startAccountId = codexAccount.id;
|
|
40
59
|
const bridge = codexDesktopRendererBridge();
|
|
41
60
|
profile.rendererBridges = [bridge, ...profile.rendererBridges];
|
|
42
61
|
profile.providers = providersFromRendererBridges(profile.rendererBridges);
|
|
@@ -44,9 +63,9 @@ export async function navigateProfile(options = {}) {
|
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
export function configuredManifestPaths(baseManifestPaths, options = {}) {
|
|
47
|
-
const runtimePaths =
|
|
48
|
-
const configPath = options.registryConfigPath || process.env.
|
|
49
|
-
const profileId = options.registryProfileId || process.env.
|
|
66
|
+
const runtimePaths = clankerbendRuntimePaths({ stateDir: options.stateDir });
|
|
67
|
+
const configPath = options.registryConfigPath || process.env.ONEWILL_CLANKERBEND_REGISTRY_CONFIG || runtimePaths.registryConfigPath;
|
|
68
|
+
const profileId = options.registryProfileId || process.env.ONEWILL_CLANKERBEND_PROFILE || "default";
|
|
50
69
|
const config = loadRegistryConfig(configPath);
|
|
51
70
|
return mergeManifestPaths(baseManifestPaths, enabledManifestPathsFromConfig(config, profileId));
|
|
52
71
|
}
|
|
@@ -81,13 +100,18 @@ function codexDesktopRendererBridge() {
|
|
|
81
100
|
export function printLaunchStatus(profile, host, options = {}) {
|
|
82
101
|
const mode = options.mockMode ? "Mock transcript" : "Codex Desktop";
|
|
83
102
|
console.log("");
|
|
84
|
-
console.log(`
|
|
103
|
+
console.log(`ClankerBend profile: ${profile.launchProfileName || profile.name || "Default"}`);
|
|
85
104
|
console.log(`Status: ${mode} is running`);
|
|
86
|
-
console.log(`Panel: ${host.state.panel.url}`);
|
|
87
105
|
console.log(`Host: ${host.state.host.url}`);
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
console.log("Press Ctrl+C to stop OneWhack and the launched Codex Desktop process.");
|
|
106
|
+
if (host.token) console.log(`Token: ${host.token}`);
|
|
107
|
+
console.log("Press Ctrl+C to stop ClankerBend and the launched Codex Desktop process.");
|
|
92
108
|
console.log("");
|
|
93
109
|
}
|
|
110
|
+
|
|
111
|
+
function profileNameFromId(profileId) {
|
|
112
|
+
return String(profileId || "default")
|
|
113
|
+
.split(/[-_\s]+/)
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
|
116
|
+
.join(" ") || "Default";
|
|
117
|
+
}
|
package/launch/runtime-paths.mjs
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { homedir, platform } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
-
export function
|
|
5
|
-
if (process.env.
|
|
4
|
+
export function clankerbendStateDir() {
|
|
5
|
+
if (process.env.ONEWILL_CLANKERBEND_STATE_DIR) return process.env.ONEWILL_CLANKERBEND_STATE_DIR;
|
|
6
6
|
if (platform() === "darwin") {
|
|
7
|
-
return join(homedir(), "Library/Application Support/OneWill/
|
|
7
|
+
return join(homedir(), "Library/Application Support/OneWill/ClankerBend");
|
|
8
8
|
}
|
|
9
|
-
return join(homedir(), ".local/state/onewill/
|
|
9
|
+
return join(homedir(), ".local/state/onewill/clankerbend");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function
|
|
13
|
-
const root = options.stateDir ||
|
|
12
|
+
export function clankerbendRuntimePaths(options = {}) {
|
|
13
|
+
const root = options.stateDir || clankerbendStateDir();
|
|
14
14
|
return {
|
|
15
15
|
root,
|
|
16
16
|
runDir: join(root, "run"),
|
|
17
17
|
registryConfigPath: join(root, "registry.json"),
|
|
18
|
+
accountRegistryPath: join(root, "accounts.json"),
|
|
19
|
+
accountProfilesDir: join(root, "accounts"),
|
|
20
|
+
deletedAccountProfilesDir: join(root, "deleted-accounts"),
|
|
18
21
|
appInstallDir: join(root, "apps"),
|
|
19
22
|
codexProfileDir: join(root, "codex-profile")
|
|
20
23
|
};
|