@nervmor/codexui 1.0.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/LICENSE +11 -0
- package/README.md +195 -0
- package/dist/assets/index-BVMDEsG8.js +1440 -0
- package/dist/assets/index-CN8KEIbg.css +1 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/codexui-icon.svg +52 -0
- package/dist/icons/maskable-512x512.png +0 -0
- package/dist/icons/pwa-192x192.png +0 -0
- package/dist/icons/pwa-512x512.png +0 -0
- package/dist/icons/pwa-icon.svg +38 -0
- package/dist/icons/pwa-maskable.svg +36 -0
- package/dist/index.html +21 -0
- package/dist/manifest.webmanifest +36 -0
- package/dist/sw.js +73 -0
- package/dist-cli/index.js +3291 -0
- package/dist-cli/index.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,3291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { createServer as createServer2 } from "http";
|
|
5
|
+
import { chmodSync, createWriteStream, existsSync as existsSync3, mkdirSync } from "fs";
|
|
6
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
7
|
+
import { homedir as homedir3, networkInterfaces } from "os";
|
|
8
|
+
import { join as join5 } from "path";
|
|
9
|
+
import { spawn as spawn3, spawnSync } from "child_process";
|
|
10
|
+
import { createInterface } from "readline/promises";
|
|
11
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
12
|
+
import { dirname as dirname3 } from "path";
|
|
13
|
+
import { get as httpsGet } from "https";
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
import qrcode from "qrcode-terminal";
|
|
16
|
+
|
|
17
|
+
// src/server/httpServer.ts
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join4 } from "path";
|
|
20
|
+
import { existsSync as existsSync2 } from "fs";
|
|
21
|
+
import { writeFile as writeFile3, stat as stat4 } from "fs/promises";
|
|
22
|
+
import express from "express";
|
|
23
|
+
|
|
24
|
+
// src/server/codexAppServerBridge.ts
|
|
25
|
+
import { spawn as spawn2 } from "child_process";
|
|
26
|
+
import { randomBytes } from "crypto";
|
|
27
|
+
import { mkdtemp as mkdtemp2, readFile as readFile2, mkdir as mkdir2, stat as stat2 } from "fs/promises";
|
|
28
|
+
import { request as httpsRequest } from "https";
|
|
29
|
+
import { homedir as homedir2 } from "os";
|
|
30
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
31
|
+
import { basename, isAbsolute, join as join2, resolve } from "path";
|
|
32
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
33
|
+
|
|
34
|
+
// src/server/skillsRoutes.ts
|
|
35
|
+
import { spawn } from "child_process";
|
|
36
|
+
import { mkdtemp, readFile, readdir, rm, mkdir, stat, lstat, readlink, symlink } from "fs/promises";
|
|
37
|
+
import { existsSync } from "fs";
|
|
38
|
+
import { homedir, tmpdir } from "os";
|
|
39
|
+
import { join } from "path";
|
|
40
|
+
import { writeFile } from "fs/promises";
|
|
41
|
+
function asRecord(value) {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
43
|
+
}
|
|
44
|
+
function getErrorMessage(payload, fallback) {
|
|
45
|
+
if (payload instanceof Error && payload.message.trim().length > 0) {
|
|
46
|
+
return payload.message;
|
|
47
|
+
}
|
|
48
|
+
const record = asRecord(payload);
|
|
49
|
+
if (!record) return fallback;
|
|
50
|
+
const error = record.error;
|
|
51
|
+
if (typeof error === "string" && error.length > 0) return error;
|
|
52
|
+
const nestedError = asRecord(error);
|
|
53
|
+
if (nestedError && typeof nestedError.message === "string" && nestedError.message.length > 0) {
|
|
54
|
+
return nestedError.message;
|
|
55
|
+
}
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
function setJson(res, statusCode, payload) {
|
|
59
|
+
res.statusCode = statusCode;
|
|
60
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
61
|
+
res.end(JSON.stringify(payload));
|
|
62
|
+
}
|
|
63
|
+
function getCodexHomeDir() {
|
|
64
|
+
const codexHome = process.env.CODEX_HOME?.trim();
|
|
65
|
+
return codexHome && codexHome.length > 0 ? codexHome : join(homedir(), ".codex");
|
|
66
|
+
}
|
|
67
|
+
function getSkillsInstallDir() {
|
|
68
|
+
return join(getCodexHomeDir(), "skills");
|
|
69
|
+
}
|
|
70
|
+
async function runCommand(command, args, options = {}) {
|
|
71
|
+
await new Promise((resolve2, reject) => {
|
|
72
|
+
const proc = spawn(command, args, {
|
|
73
|
+
cwd: options.cwd,
|
|
74
|
+
env: process.env,
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
76
|
+
});
|
|
77
|
+
let stdout = "";
|
|
78
|
+
let stderr = "";
|
|
79
|
+
proc.stdout.on("data", (chunk) => {
|
|
80
|
+
stdout += chunk.toString();
|
|
81
|
+
});
|
|
82
|
+
proc.stderr.on("data", (chunk) => {
|
|
83
|
+
stderr += chunk.toString();
|
|
84
|
+
});
|
|
85
|
+
proc.on("error", reject);
|
|
86
|
+
proc.on("close", (code) => {
|
|
87
|
+
if (code === 0) {
|
|
88
|
+
resolve2();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
92
|
+
const suffix = details.length > 0 ? `: ${details}` : "";
|
|
93
|
+
reject(new Error(`Command failed (${command} ${args.join(" ")})${suffix}`));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function runCommandWithOutput(command, args, options = {}) {
|
|
98
|
+
return await new Promise((resolve2, reject) => {
|
|
99
|
+
const proc = spawn(command, args, {
|
|
100
|
+
cwd: options.cwd,
|
|
101
|
+
env: process.env,
|
|
102
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
103
|
+
});
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
proc.stdout.on("data", (chunk) => {
|
|
107
|
+
stdout += chunk.toString();
|
|
108
|
+
});
|
|
109
|
+
proc.stderr.on("data", (chunk) => {
|
|
110
|
+
stderr += chunk.toString();
|
|
111
|
+
});
|
|
112
|
+
proc.on("error", reject);
|
|
113
|
+
proc.on("close", (code) => {
|
|
114
|
+
if (code === 0) {
|
|
115
|
+
resolve2(stdout.trim());
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
119
|
+
const suffix = details.length > 0 ? `: ${details}` : "";
|
|
120
|
+
reject(new Error(`Command failed (${command} ${args.join(" ")})${suffix}`));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function detectUserSkillsDir(appServer) {
|
|
125
|
+
try {
|
|
126
|
+
const result = await appServer.rpc("skills/list", {});
|
|
127
|
+
for (const entry of result.data ?? []) {
|
|
128
|
+
for (const skill of entry.skills ?? []) {
|
|
129
|
+
if (skill.scope !== "user" || !skill.path) continue;
|
|
130
|
+
const parts = skill.path.split("/").filter(Boolean);
|
|
131
|
+
if (parts.length < 2) continue;
|
|
132
|
+
return `/${parts.slice(0, -2).join("/")}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
return getSkillsInstallDir();
|
|
138
|
+
}
|
|
139
|
+
async function ensureInstalledSkillIsValid(appServer, skillPath) {
|
|
140
|
+
const result = await appServer.rpc("skills/list", { forceReload: true });
|
|
141
|
+
const normalized = skillPath.endsWith("/SKILL.md") ? skillPath : `${skillPath}/SKILL.md`;
|
|
142
|
+
for (const entry of result.data ?? []) {
|
|
143
|
+
for (const error of entry.errors ?? []) {
|
|
144
|
+
if (error.path === normalized) {
|
|
145
|
+
throw new Error(error.message || "Installed skill is invalid");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
var TREE_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
151
|
+
var skillsTreeCache = null;
|
|
152
|
+
var metaCache = /* @__PURE__ */ new Map();
|
|
153
|
+
async function getGhToken() {
|
|
154
|
+
try {
|
|
155
|
+
const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
156
|
+
let out = "";
|
|
157
|
+
proc.stdout.on("data", (d) => {
|
|
158
|
+
out += d.toString();
|
|
159
|
+
});
|
|
160
|
+
return new Promise((resolve2) => {
|
|
161
|
+
proc.on("close", (code) => resolve2(code === 0 ? out.trim() : null));
|
|
162
|
+
proc.on("error", () => resolve2(null));
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function ghFetch(url) {
|
|
169
|
+
const token = await getGhToken();
|
|
170
|
+
const headers = {
|
|
171
|
+
Accept: "application/vnd.github+json",
|
|
172
|
+
"User-Agent": "codex-web-local"
|
|
173
|
+
};
|
|
174
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
175
|
+
return fetch(url, { headers });
|
|
176
|
+
}
|
|
177
|
+
async function fetchSkillsTree() {
|
|
178
|
+
if (skillsTreeCache && Date.now() - skillsTreeCache.fetchedAt < TREE_CACHE_TTL_MS) {
|
|
179
|
+
return skillsTreeCache.entries;
|
|
180
|
+
}
|
|
181
|
+
const resp = await ghFetch(`https://api.github.com/repos/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/git/trees/main?recursive=1`);
|
|
182
|
+
if (!resp.ok) throw new Error(`GitHub tree API returned ${resp.status}`);
|
|
183
|
+
const data = await resp.json();
|
|
184
|
+
const metaPattern = /^skills\/([^/]+)\/([^/]+)\/_meta\.json$/;
|
|
185
|
+
const seen = /* @__PURE__ */ new Set();
|
|
186
|
+
const entries = [];
|
|
187
|
+
for (const node of data.tree ?? []) {
|
|
188
|
+
const match = metaPattern.exec(node.path);
|
|
189
|
+
if (!match) continue;
|
|
190
|
+
const [, owner, skillName] = match;
|
|
191
|
+
const key = `${owner}/${skillName}`;
|
|
192
|
+
if (seen.has(key)) continue;
|
|
193
|
+
seen.add(key);
|
|
194
|
+
entries.push({
|
|
195
|
+
name: skillName,
|
|
196
|
+
owner,
|
|
197
|
+
url: `https://github.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/tree/main/skills/${owner}/${skillName}`
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
skillsTreeCache = { entries, fetchedAt: Date.now() };
|
|
201
|
+
return entries;
|
|
202
|
+
}
|
|
203
|
+
async function fetchMetaBatch(entries) {
|
|
204
|
+
const toFetch = entries.filter((e) => !metaCache.has(`${e.owner}/${e.name}`));
|
|
205
|
+
if (toFetch.length === 0) return;
|
|
206
|
+
const batch = toFetch.slice(0, 50);
|
|
207
|
+
await Promise.allSettled(
|
|
208
|
+
batch.map(async (e) => {
|
|
209
|
+
const rawUrl = `https://raw.githubusercontent.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/main/skills/${e.owner}/${e.name}/_meta.json`;
|
|
210
|
+
const resp = await fetch(rawUrl);
|
|
211
|
+
if (!resp.ok) return;
|
|
212
|
+
const meta = await resp.json();
|
|
213
|
+
metaCache.set(`${e.owner}/${e.name}`, {
|
|
214
|
+
displayName: typeof meta.displayName === "string" ? meta.displayName : "",
|
|
215
|
+
description: typeof meta.displayName === "string" ? meta.displayName : "",
|
|
216
|
+
publishedAt: meta.latest?.publishedAt ?? 0
|
|
217
|
+
});
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
function buildHubEntry(e) {
|
|
222
|
+
const cached = metaCache.get(`${e.owner}/${e.name}`);
|
|
223
|
+
return {
|
|
224
|
+
name: e.name,
|
|
225
|
+
owner: e.owner,
|
|
226
|
+
description: cached?.description ?? "",
|
|
227
|
+
displayName: cached?.displayName ?? "",
|
|
228
|
+
publishedAt: cached?.publishedAt ?? 0,
|
|
229
|
+
avatarUrl: `https://github.com/${e.owner}.png?size=40`,
|
|
230
|
+
url: e.url,
|
|
231
|
+
installed: false
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
var GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
235
|
+
var DEFAULT_SKILLS_SYNC_REPO_NAME = "codexskills";
|
|
236
|
+
var SKILLS_SYNC_MANIFEST_PATH = "installed-skills.json";
|
|
237
|
+
var SYNC_UPSTREAM_SKILLS_OWNER = "OpenClawAndroid";
|
|
238
|
+
var SYNC_UPSTREAM_SKILLS_REPO = "skills";
|
|
239
|
+
var HUB_SKILLS_OWNER = "openclaw";
|
|
240
|
+
var HUB_SKILLS_REPO = "skills";
|
|
241
|
+
var startupSkillsSyncInitialized = false;
|
|
242
|
+
var startupSyncStatus = {
|
|
243
|
+
inProgress: false,
|
|
244
|
+
mode: "idle",
|
|
245
|
+
branch: getPreferredSyncBranch(),
|
|
246
|
+
lastAction: "not-started",
|
|
247
|
+
lastRunAtIso: "",
|
|
248
|
+
lastSuccessAtIso: "",
|
|
249
|
+
lastError: ""
|
|
250
|
+
};
|
|
251
|
+
async function scanInstalledSkillsFromDisk() {
|
|
252
|
+
const map = /* @__PURE__ */ new Map();
|
|
253
|
+
const skillsDir = getSkillsInstallDir();
|
|
254
|
+
try {
|
|
255
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
258
|
+
const skillMd = join(skillsDir, entry.name, "SKILL.md");
|
|
259
|
+
try {
|
|
260
|
+
await stat(skillMd);
|
|
261
|
+
map.set(entry.name, { name: entry.name, path: skillMd, enabled: true });
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
return map;
|
|
268
|
+
}
|
|
269
|
+
function getSkillsSyncStatePath() {
|
|
270
|
+
return join(getCodexHomeDir(), "skills-sync.json");
|
|
271
|
+
}
|
|
272
|
+
async function readSkillsSyncState() {
|
|
273
|
+
try {
|
|
274
|
+
const raw = await readFile(getSkillsSyncStatePath(), "utf8");
|
|
275
|
+
const parsed = JSON.parse(raw);
|
|
276
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
277
|
+
} catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function writeSkillsSyncState(state) {
|
|
282
|
+
await writeFile(getSkillsSyncStatePath(), JSON.stringify(state), "utf8");
|
|
283
|
+
}
|
|
284
|
+
async function getGithubJson(url, token, method = "GET", body) {
|
|
285
|
+
const resp = await fetch(url, {
|
|
286
|
+
method,
|
|
287
|
+
headers: {
|
|
288
|
+
Accept: "application/vnd.github+json",
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
Authorization: `Bearer ${token}`,
|
|
291
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
292
|
+
"User-Agent": "codex-web-local"
|
|
293
|
+
},
|
|
294
|
+
body: body ? JSON.stringify(body) : void 0
|
|
295
|
+
});
|
|
296
|
+
if (!resp.ok) {
|
|
297
|
+
const text = await resp.text();
|
|
298
|
+
throw new Error(`GitHub API ${method} ${url} failed (${resp.status}): ${text}`);
|
|
299
|
+
}
|
|
300
|
+
return await resp.json();
|
|
301
|
+
}
|
|
302
|
+
async function startGithubDeviceLogin() {
|
|
303
|
+
const resp = await fetch("https://github.com/login/device/code", {
|
|
304
|
+
method: "POST",
|
|
305
|
+
headers: {
|
|
306
|
+
Accept: "application/json",
|
|
307
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
308
|
+
"User-Agent": "codex-web-local"
|
|
309
|
+
},
|
|
310
|
+
body: new URLSearchParams({
|
|
311
|
+
client_id: GITHUB_DEVICE_CLIENT_ID,
|
|
312
|
+
scope: "repo read:user"
|
|
313
|
+
})
|
|
314
|
+
});
|
|
315
|
+
if (!resp.ok) {
|
|
316
|
+
throw new Error(`GitHub device flow init failed (${resp.status})`);
|
|
317
|
+
}
|
|
318
|
+
return await resp.json();
|
|
319
|
+
}
|
|
320
|
+
async function completeGithubDeviceLogin(deviceCode) {
|
|
321
|
+
const resp = await fetch("https://github.com/login/oauth/access_token", {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
Accept: "application/json",
|
|
325
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
326
|
+
"User-Agent": "codex-web-local"
|
|
327
|
+
},
|
|
328
|
+
body: new URLSearchParams({
|
|
329
|
+
client_id: GITHUB_DEVICE_CLIENT_ID,
|
|
330
|
+
device_code: deviceCode,
|
|
331
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
332
|
+
})
|
|
333
|
+
});
|
|
334
|
+
if (!resp.ok) {
|
|
335
|
+
throw new Error(`GitHub token exchange failed (${resp.status})`);
|
|
336
|
+
}
|
|
337
|
+
const payload = await resp.json();
|
|
338
|
+
if (!payload.access_token) return { token: null, error: payload.error || "unknown_error" };
|
|
339
|
+
return { token: payload.access_token, error: null };
|
|
340
|
+
}
|
|
341
|
+
function isAndroidLikeRuntime() {
|
|
342
|
+
if (process.platform === "android") return true;
|
|
343
|
+
if (existsSync("/data/data/com.termux")) return true;
|
|
344
|
+
if (process.env.TERMUX_VERSION) return true;
|
|
345
|
+
const prefix = process.env.PREFIX?.toLowerCase() ?? "";
|
|
346
|
+
if (prefix.includes("/com.termux/")) return true;
|
|
347
|
+
const proot = process.env.PROOT_TMP_DIR?.toLowerCase() ?? "";
|
|
348
|
+
return proot.length > 0;
|
|
349
|
+
}
|
|
350
|
+
function getPreferredSyncBranch() {
|
|
351
|
+
return isAndroidLikeRuntime() ? "android" : "main";
|
|
352
|
+
}
|
|
353
|
+
function isUpstreamSkillsRepo(repoOwner, repoName) {
|
|
354
|
+
return repoOwner.toLowerCase() === SYNC_UPSTREAM_SKILLS_OWNER.toLowerCase() && repoName.toLowerCase() === SYNC_UPSTREAM_SKILLS_REPO.toLowerCase();
|
|
355
|
+
}
|
|
356
|
+
async function resolveGithubUsername(token) {
|
|
357
|
+
const user = await getGithubJson("https://api.github.com/user", token);
|
|
358
|
+
return user.login;
|
|
359
|
+
}
|
|
360
|
+
async function ensurePrivateForkFromUpstream(token, username, repoName) {
|
|
361
|
+
const repoUrl = `https://api.github.com/repos/${username}/${repoName}`;
|
|
362
|
+
let created = false;
|
|
363
|
+
const existing = await fetch(repoUrl, {
|
|
364
|
+
headers: {
|
|
365
|
+
Accept: "application/vnd.github+json",
|
|
366
|
+
Authorization: `Bearer ${token}`,
|
|
367
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
368
|
+
"User-Agent": "codex-web-local"
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
if (existing.ok) {
|
|
372
|
+
const details = await existing.json();
|
|
373
|
+
if (details.private === true) return;
|
|
374
|
+
await getGithubJson(repoUrl, token, "PATCH", { private: true });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (existing.status !== 404) {
|
|
378
|
+
throw new Error(`Failed to check personal repo existence (${existing.status})`);
|
|
379
|
+
}
|
|
380
|
+
await getGithubJson(
|
|
381
|
+
"https://api.github.com/user/repos",
|
|
382
|
+
token,
|
|
383
|
+
"POST",
|
|
384
|
+
{ name: repoName, private: true, auto_init: false, description: "Codex skills private mirror sync" }
|
|
385
|
+
);
|
|
386
|
+
created = true;
|
|
387
|
+
let ready = false;
|
|
388
|
+
for (let i = 0; i < 20; i++) {
|
|
389
|
+
const check = await fetch(repoUrl, {
|
|
390
|
+
headers: {
|
|
391
|
+
Accept: "application/vnd.github+json",
|
|
392
|
+
Authorization: `Bearer ${token}`,
|
|
393
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
394
|
+
"User-Agent": "codex-web-local"
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
if (check.ok) {
|
|
398
|
+
ready = true;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
402
|
+
}
|
|
403
|
+
if (!ready) throw new Error("Private mirror repo was created but is not available yet");
|
|
404
|
+
if (!created) return;
|
|
405
|
+
const tmp = await mkdtemp(join(tmpdir(), "codex-skills-seed-"));
|
|
406
|
+
try {
|
|
407
|
+
const upstreamUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git`;
|
|
408
|
+
const branch = getPreferredSyncBranch();
|
|
409
|
+
try {
|
|
410
|
+
await runCommand("git", ["clone", "--depth", "1", "--single-branch", "--branch", branch, upstreamUrl, tmp]);
|
|
411
|
+
} catch {
|
|
412
|
+
await runCommand("git", ["clone", "--depth", "1", upstreamUrl, tmp]);
|
|
413
|
+
}
|
|
414
|
+
const privateRemote = toGitHubTokenRemote(username, repoName, token);
|
|
415
|
+
await runCommand("git", ["remote", "set-url", "origin", privateRemote], { cwd: tmp });
|
|
416
|
+
try {
|
|
417
|
+
await runCommand("git", ["checkout", "-B", branch], { cwd: tmp });
|
|
418
|
+
} catch {
|
|
419
|
+
}
|
|
420
|
+
await runCommand("git", ["push", "-u", "origin", `HEAD:${branch}`], { cwd: tmp });
|
|
421
|
+
} finally {
|
|
422
|
+
await rm(tmp, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function readRemoteSkillsManifest(token, repoOwner, repoName) {
|
|
426
|
+
const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}`;
|
|
427
|
+
const resp = await fetch(url, {
|
|
428
|
+
headers: {
|
|
429
|
+
Accept: "application/vnd.github+json",
|
|
430
|
+
Authorization: `Bearer ${token}`,
|
|
431
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
432
|
+
"User-Agent": "codex-web-local"
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
if (resp.status === 404) return [];
|
|
436
|
+
if (!resp.ok) throw new Error(`Failed to read remote manifest (${resp.status})`);
|
|
437
|
+
const payload = await resp.json();
|
|
438
|
+
const content = payload.content ? Buffer.from(payload.content.replace(/\n/g, ""), "base64").toString("utf8") : "[]";
|
|
439
|
+
const parsed = JSON.parse(content);
|
|
440
|
+
if (!Array.isArray(parsed)) return [];
|
|
441
|
+
const skills = [];
|
|
442
|
+
for (const row of parsed) {
|
|
443
|
+
const item = asRecord(row);
|
|
444
|
+
const owner = typeof item?.owner === "string" ? item.owner : "";
|
|
445
|
+
const name = typeof item?.name === "string" ? item.name : "";
|
|
446
|
+
if (!name) continue;
|
|
447
|
+
skills.push({ ...owner ? { owner } : {}, name, enabled: item?.enabled !== false });
|
|
448
|
+
}
|
|
449
|
+
return skills;
|
|
450
|
+
}
|
|
451
|
+
async function writeRemoteSkillsManifest(token, repoOwner, repoName, skills) {
|
|
452
|
+
const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}`;
|
|
453
|
+
let sha = "";
|
|
454
|
+
const existing = await fetch(url, {
|
|
455
|
+
headers: {
|
|
456
|
+
Accept: "application/vnd.github+json",
|
|
457
|
+
Authorization: `Bearer ${token}`,
|
|
458
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
459
|
+
"User-Agent": "codex-web-local"
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
if (existing.ok) {
|
|
463
|
+
const payload = await existing.json();
|
|
464
|
+
sha = payload.sha ?? "";
|
|
465
|
+
}
|
|
466
|
+
const content = Buffer.from(JSON.stringify(skills, null, 2), "utf8").toString("base64");
|
|
467
|
+
await getGithubJson(url, token, "PUT", {
|
|
468
|
+
message: "Update synced skills manifest",
|
|
469
|
+
content,
|
|
470
|
+
...sha ? { sha } : {}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function toGitHubTokenRemote(repoOwner, repoName, token) {
|
|
474
|
+
return `https://x-access-token:${encodeURIComponent(token)}@github.com/${repoOwner}/${repoName}.git`;
|
|
475
|
+
}
|
|
476
|
+
async function ensureSkillsWorkingTreeRepo(repoUrl, branch) {
|
|
477
|
+
const localDir = getSkillsInstallDir();
|
|
478
|
+
await mkdir(localDir, { recursive: true });
|
|
479
|
+
const gitDir = join(localDir, ".git");
|
|
480
|
+
let hasGitDir = false;
|
|
481
|
+
try {
|
|
482
|
+
hasGitDir = (await stat(gitDir)).isDirectory();
|
|
483
|
+
} catch {
|
|
484
|
+
hasGitDir = false;
|
|
485
|
+
}
|
|
486
|
+
if (!hasGitDir) {
|
|
487
|
+
await runCommand("git", ["init"], { cwd: localDir });
|
|
488
|
+
await runCommand("git", ["config", "user.email", "skills-sync@local"], { cwd: localDir });
|
|
489
|
+
await runCommand("git", ["config", "user.name", "Skills Sync"], { cwd: localDir });
|
|
490
|
+
await runCommand("git", ["add", "-A"], { cwd: localDir });
|
|
491
|
+
try {
|
|
492
|
+
await runCommand("git", ["commit", "-m", "Local skills snapshot before sync"], { cwd: localDir });
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
await runCommand("git", ["branch", "-M", branch], { cwd: localDir });
|
|
496
|
+
try {
|
|
497
|
+
await runCommand("git", ["remote", "add", "origin", repoUrl], { cwd: localDir });
|
|
498
|
+
} catch {
|
|
499
|
+
await runCommand("git", ["remote", "set-url", "origin", repoUrl], { cwd: localDir });
|
|
500
|
+
}
|
|
501
|
+
await runCommand("git", ["fetch", "origin"], { cwd: localDir });
|
|
502
|
+
try {
|
|
503
|
+
await runCommand("git", ["merge", "--allow-unrelated-histories", "--no-edit", `origin/${branch}`], { cwd: localDir });
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
return localDir;
|
|
507
|
+
}
|
|
508
|
+
await runCommand("git", ["remote", "set-url", "origin", repoUrl], { cwd: localDir });
|
|
509
|
+
await runCommand("git", ["fetch", "origin"], { cwd: localDir });
|
|
510
|
+
await resolveMergeConflictsByNewerCommit(localDir, branch);
|
|
511
|
+
try {
|
|
512
|
+
await runCommand("git", ["checkout", branch], { cwd: localDir });
|
|
513
|
+
} catch {
|
|
514
|
+
await resolveMergeConflictsByNewerCommit(localDir, branch);
|
|
515
|
+
await runCommand("git", ["checkout", "-B", branch], { cwd: localDir });
|
|
516
|
+
}
|
|
517
|
+
await resolveMergeConflictsByNewerCommit(localDir, branch);
|
|
518
|
+
const localMtimesBeforePull = await snapshotFileMtimes(localDir);
|
|
519
|
+
try {
|
|
520
|
+
await runCommand("git", ["stash", "push", "--include-untracked", "-m", "codex-skills-autostash"], { cwd: localDir });
|
|
521
|
+
} catch {
|
|
522
|
+
}
|
|
523
|
+
let pulledMtimes = /* @__PURE__ */ new Map();
|
|
524
|
+
try {
|
|
525
|
+
await runCommand("git", ["pull", "--no-rebase", "origin", branch], { cwd: localDir });
|
|
526
|
+
pulledMtimes = await snapshotFileMtimes(localDir);
|
|
527
|
+
} catch {
|
|
528
|
+
await resolveMergeConflictsByNewerCommit(localDir, branch);
|
|
529
|
+
pulledMtimes = await snapshotFileMtimes(localDir);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await runCommand("git", ["stash", "pop"], { cwd: localDir });
|
|
533
|
+
} catch {
|
|
534
|
+
await resolveStashPopConflictsByFileTime(localDir, localMtimesBeforePull, pulledMtimes);
|
|
535
|
+
}
|
|
536
|
+
return localDir;
|
|
537
|
+
}
|
|
538
|
+
async function resolveMergeConflictsByNewerCommit(repoDir, branch) {
|
|
539
|
+
const unmerged = (await runCommandWithOutput("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: repoDir })).split(/\r?\n/).map((row) => row.trim()).filter(Boolean);
|
|
540
|
+
if (unmerged.length === 0) return;
|
|
541
|
+
for (const path of unmerged) {
|
|
542
|
+
const oursTime = await getCommitTime(repoDir, "HEAD", path);
|
|
543
|
+
const theirsTime = await getCommitTime(repoDir, `origin/${branch}`, path);
|
|
544
|
+
if (theirsTime > oursTime) {
|
|
545
|
+
await runCommand("git", ["checkout", "--theirs", "--", path], { cwd: repoDir });
|
|
546
|
+
} else {
|
|
547
|
+
await runCommand("git", ["checkout", "--ours", "--", path], { cwd: repoDir });
|
|
548
|
+
}
|
|
549
|
+
await runCommand("git", ["add", "--", path], { cwd: repoDir });
|
|
550
|
+
}
|
|
551
|
+
const mergeHead = (await runCommandWithOutput("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir })).trim();
|
|
552
|
+
if (mergeHead) {
|
|
553
|
+
await runCommand("git", ["commit", "-m", "Auto-resolve skills merge by newer file"], { cwd: repoDir });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function getCommitTime(repoDir, ref, path) {
|
|
557
|
+
try {
|
|
558
|
+
const output = (await runCommandWithOutput("git", ["log", "-1", "--format=%ct", ref, "--", path], { cwd: repoDir })).trim();
|
|
559
|
+
return output ? Number.parseInt(output, 10) : 0;
|
|
560
|
+
} catch {
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async function resolveStashPopConflictsByFileTime(repoDir, localMtimesBeforePull, pulledMtimes) {
|
|
565
|
+
const unmerged = (await runCommandWithOutput("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: repoDir })).split(/\r?\n/).map((row) => row.trim()).filter(Boolean);
|
|
566
|
+
if (unmerged.length === 0) return;
|
|
567
|
+
for (const path of unmerged) {
|
|
568
|
+
const localMtime = localMtimesBeforePull.get(path) ?? 0;
|
|
569
|
+
const pulledMtime = pulledMtimes.get(path) ?? 0;
|
|
570
|
+
const side = localMtime >= pulledMtime ? "--theirs" : "--ours";
|
|
571
|
+
await runCommand("git", ["checkout", side, "--", path], { cwd: repoDir });
|
|
572
|
+
await runCommand("git", ["add", "--", path], { cwd: repoDir });
|
|
573
|
+
}
|
|
574
|
+
const mergeHead = (await runCommandWithOutput("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir })).trim();
|
|
575
|
+
if (mergeHead) {
|
|
576
|
+
await runCommand("git", ["commit", "-m", "Auto-resolve stash-pop conflicts by file time"], { cwd: repoDir });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function snapshotFileMtimes(dir) {
|
|
580
|
+
const mtimes = /* @__PURE__ */ new Map();
|
|
581
|
+
await walkFileMtimes(dir, dir, mtimes);
|
|
582
|
+
return mtimes;
|
|
583
|
+
}
|
|
584
|
+
async function walkFileMtimes(rootDir, currentDir, out) {
|
|
585
|
+
let entries;
|
|
586
|
+
try {
|
|
587
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
588
|
+
} catch {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
for (const entry of entries) {
|
|
592
|
+
const entryName = String(entry.name);
|
|
593
|
+
if (entryName === ".git") continue;
|
|
594
|
+
const absolutePath = join(currentDir, entryName);
|
|
595
|
+
const relativePath = absolutePath.slice(rootDir.length + 1);
|
|
596
|
+
if (entry.isDirectory()) {
|
|
597
|
+
await walkFileMtimes(rootDir, absolutePath, out);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (!entry.isFile()) continue;
|
|
601
|
+
try {
|
|
602
|
+
const info = await stat(absolutePath);
|
|
603
|
+
out.set(relativePath, info.mtimeMs);
|
|
604
|
+
} catch {
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async function syncInstalledSkillsFolderToRepo(token, repoOwner, repoName, _installedMap) {
|
|
609
|
+
const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token);
|
|
610
|
+
const branch = getPreferredSyncBranch();
|
|
611
|
+
const repoDir = await ensureSkillsWorkingTreeRepo(remoteUrl, branch);
|
|
612
|
+
void _installedMap;
|
|
613
|
+
await runCommand("git", ["config", "user.email", "skills-sync@local"], { cwd: repoDir });
|
|
614
|
+
await runCommand("git", ["config", "user.name", "Skills Sync"], { cwd: repoDir });
|
|
615
|
+
await runCommand("git", ["add", "."], { cwd: repoDir });
|
|
616
|
+
const status = (await runCommandWithOutput("git", ["status", "--porcelain"], { cwd: repoDir })).trim();
|
|
617
|
+
if (!status) return;
|
|
618
|
+
await runCommand("git", ["commit", "-m", "Sync installed skills folder and manifest"], { cwd: repoDir });
|
|
619
|
+
await runCommand("git", ["push", "origin", `HEAD:${branch}`], { cwd: repoDir });
|
|
620
|
+
}
|
|
621
|
+
async function pullInstalledSkillsFolderFromRepo(token, repoOwner, repoName) {
|
|
622
|
+
const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token);
|
|
623
|
+
const branch = getPreferredSyncBranch();
|
|
624
|
+
await ensureSkillsWorkingTreeRepo(remoteUrl, branch);
|
|
625
|
+
}
|
|
626
|
+
async function bootstrapSkillsFromUpstreamIntoLocal() {
|
|
627
|
+
const repoUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git`;
|
|
628
|
+
const branch = getPreferredSyncBranch();
|
|
629
|
+
await ensureSkillsWorkingTreeRepo(repoUrl, branch);
|
|
630
|
+
}
|
|
631
|
+
async function collectLocalSyncedSkills(appServer) {
|
|
632
|
+
const state = await readSkillsSyncState();
|
|
633
|
+
const owners = { ...state.installedOwners ?? {} };
|
|
634
|
+
const tree = await fetchSkillsTree();
|
|
635
|
+
const uniqueOwnerByName = /* @__PURE__ */ new Map();
|
|
636
|
+
const ambiguousNames = /* @__PURE__ */ new Set();
|
|
637
|
+
for (const entry of tree) {
|
|
638
|
+
if (ambiguousNames.has(entry.name)) continue;
|
|
639
|
+
const existingOwner = uniqueOwnerByName.get(entry.name);
|
|
640
|
+
if (!existingOwner) {
|
|
641
|
+
uniqueOwnerByName.set(entry.name, entry.owner);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (existingOwner !== entry.owner) {
|
|
645
|
+
uniqueOwnerByName.delete(entry.name);
|
|
646
|
+
ambiguousNames.add(entry.name);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const skills = await appServer.rpc("skills/list", {});
|
|
650
|
+
const seen = /* @__PURE__ */ new Set();
|
|
651
|
+
const synced = [];
|
|
652
|
+
let ownersChanged = false;
|
|
653
|
+
for (const entry of skills.data ?? []) {
|
|
654
|
+
for (const skill of entry.skills ?? []) {
|
|
655
|
+
const name = typeof skill.name === "string" ? skill.name : "";
|
|
656
|
+
if (!name || seen.has(name)) continue;
|
|
657
|
+
seen.add(name);
|
|
658
|
+
let owner = owners[name];
|
|
659
|
+
if (!owner) {
|
|
660
|
+
owner = uniqueOwnerByName.get(name) ?? "";
|
|
661
|
+
if (owner) {
|
|
662
|
+
owners[name] = owner;
|
|
663
|
+
ownersChanged = true;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
synced.push({ ...owner ? { owner } : {}, name, enabled: skill.enabled !== false });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (ownersChanged) {
|
|
670
|
+
await writeSkillsSyncState({ ...state, installedOwners: owners });
|
|
671
|
+
}
|
|
672
|
+
synced.sort((a, b) => `${a.owner ?? ""}/${a.name}`.localeCompare(`${b.owner ?? ""}/${b.name}`));
|
|
673
|
+
return synced;
|
|
674
|
+
}
|
|
675
|
+
async function autoPushSyncedSkills(appServer) {
|
|
676
|
+
const state = await readSkillsSyncState();
|
|
677
|
+
if (!state.githubToken || !state.repoOwner || !state.repoName) return;
|
|
678
|
+
if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) {
|
|
679
|
+
throw new Error("Refusing to push to upstream skills repository");
|
|
680
|
+
}
|
|
681
|
+
const local = await collectLocalSyncedSkills(appServer);
|
|
682
|
+
const installedMap = await scanInstalledSkillsFromDisk();
|
|
683
|
+
await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local);
|
|
684
|
+
await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap);
|
|
685
|
+
}
|
|
686
|
+
async function ensureCodexAgentsSymlinkToSkillsAgents() {
|
|
687
|
+
const codexHomeDir = getCodexHomeDir();
|
|
688
|
+
const skillsAgentsPath = join(codexHomeDir, "skills", "AGENTS.md");
|
|
689
|
+
const codexAgentsPath = join(codexHomeDir, "AGENTS.md");
|
|
690
|
+
await mkdir(join(codexHomeDir, "skills"), { recursive: true });
|
|
691
|
+
let copiedFromCodex = false;
|
|
692
|
+
try {
|
|
693
|
+
const codexAgentsStat = await lstat(codexAgentsPath);
|
|
694
|
+
if (codexAgentsStat.isFile() || codexAgentsStat.isSymbolicLink()) {
|
|
695
|
+
const content = await readFile(codexAgentsPath, "utf8");
|
|
696
|
+
await writeFile(skillsAgentsPath, content, "utf8");
|
|
697
|
+
copiedFromCodex = true;
|
|
698
|
+
} else {
|
|
699
|
+
await rm(codexAgentsPath, { force: true, recursive: true });
|
|
700
|
+
}
|
|
701
|
+
} catch {
|
|
702
|
+
}
|
|
703
|
+
if (!copiedFromCodex) {
|
|
704
|
+
try {
|
|
705
|
+
const skillsAgentsStat = await stat(skillsAgentsPath);
|
|
706
|
+
if (!skillsAgentsStat.isFile()) {
|
|
707
|
+
await rm(skillsAgentsPath, { force: true, recursive: true });
|
|
708
|
+
await writeFile(skillsAgentsPath, "", "utf8");
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
await writeFile(skillsAgentsPath, "", "utf8");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const relativeTarget = join("skills", "AGENTS.md");
|
|
715
|
+
try {
|
|
716
|
+
const current = await lstat(codexAgentsPath);
|
|
717
|
+
if (current.isSymbolicLink()) {
|
|
718
|
+
const existingTarget = await readlink(codexAgentsPath);
|
|
719
|
+
if (existingTarget === relativeTarget) return;
|
|
720
|
+
}
|
|
721
|
+
await rm(codexAgentsPath, { force: true, recursive: true });
|
|
722
|
+
} catch {
|
|
723
|
+
}
|
|
724
|
+
await symlink(relativeTarget, codexAgentsPath);
|
|
725
|
+
}
|
|
726
|
+
async function initializeSkillsSyncOnStartup(appServer) {
|
|
727
|
+
if (startupSkillsSyncInitialized) return;
|
|
728
|
+
startupSkillsSyncInitialized = true;
|
|
729
|
+
startupSyncStatus.inProgress = true;
|
|
730
|
+
startupSyncStatus.lastRunAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
731
|
+
startupSyncStatus.lastError = "";
|
|
732
|
+
startupSyncStatus.branch = getPreferredSyncBranch();
|
|
733
|
+
try {
|
|
734
|
+
const state = await readSkillsSyncState();
|
|
735
|
+
if (!state.githubToken) {
|
|
736
|
+
await ensureCodexAgentsSymlinkToSkillsAgents();
|
|
737
|
+
if (!isAndroidLikeRuntime()) {
|
|
738
|
+
startupSyncStatus.mode = "idle";
|
|
739
|
+
startupSyncStatus.lastAction = "skip-upstream-non-android";
|
|
740
|
+
startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
startupSyncStatus.mode = "unauthenticated-bootstrap";
|
|
744
|
+
startupSyncStatus.lastAction = "pull-upstream";
|
|
745
|
+
await bootstrapSkillsFromUpstreamIntoLocal();
|
|
746
|
+
try {
|
|
747
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
748
|
+
} catch {
|
|
749
|
+
}
|
|
750
|
+
startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
751
|
+
startupSyncStatus.lastAction = "pull-upstream-complete";
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
startupSyncStatus.mode = "authenticated-fork-sync";
|
|
755
|
+
startupSyncStatus.lastAction = "ensure-private-fork";
|
|
756
|
+
const username = state.githubUsername || await resolveGithubUsername(state.githubToken);
|
|
757
|
+
const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME;
|
|
758
|
+
await ensurePrivateForkFromUpstream(state.githubToken, username, repoName);
|
|
759
|
+
await writeSkillsSyncState({ ...state, githubUsername: username, repoOwner: username, repoName });
|
|
760
|
+
startupSyncStatus.lastAction = "pull-private-fork";
|
|
761
|
+
await pullInstalledSkillsFolderFromRepo(state.githubToken, username, repoName);
|
|
762
|
+
try {
|
|
763
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
764
|
+
} catch {
|
|
765
|
+
}
|
|
766
|
+
startupSyncStatus.lastAction = "push-private-fork";
|
|
767
|
+
await autoPushSyncedSkills(appServer);
|
|
768
|
+
startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
769
|
+
startupSyncStatus.lastAction = "startup-sync-complete";
|
|
770
|
+
} catch (error) {
|
|
771
|
+
startupSyncStatus.lastError = getErrorMessage(error, "startup-sync-failed");
|
|
772
|
+
startupSyncStatus.lastAction = "startup-sync-failed";
|
|
773
|
+
} finally {
|
|
774
|
+
startupSyncStatus.inProgress = false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async function finalizeGithubLoginAndSync(token, username, appServer) {
|
|
778
|
+
const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME;
|
|
779
|
+
await ensurePrivateForkFromUpstream(token, username, repoName);
|
|
780
|
+
const current = await readSkillsSyncState();
|
|
781
|
+
await writeSkillsSyncState({ ...current, githubToken: token, githubUsername: username, repoOwner: username, repoName });
|
|
782
|
+
await pullInstalledSkillsFolderFromRepo(token, username, repoName);
|
|
783
|
+
try {
|
|
784
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
787
|
+
await autoPushSyncedSkills(appServer);
|
|
788
|
+
}
|
|
789
|
+
async function searchSkillsHub(allEntries, query, limit, sort, installedMap) {
|
|
790
|
+
const q = query.toLowerCase().trim();
|
|
791
|
+
const filtered = q ? allEntries.filter((s) => {
|
|
792
|
+
if (s.name.toLowerCase().includes(q) || s.owner.toLowerCase().includes(q)) return true;
|
|
793
|
+
const cached = metaCache.get(`${s.owner}/${s.name}`);
|
|
794
|
+
return Boolean(cached?.displayName?.toLowerCase().includes(q));
|
|
795
|
+
}) : allEntries;
|
|
796
|
+
const page = filtered.slice(0, Math.min(limit * 2, 200));
|
|
797
|
+
await fetchMetaBatch(page);
|
|
798
|
+
let results = page.map(buildHubEntry);
|
|
799
|
+
if (sort === "date") {
|
|
800
|
+
results.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
801
|
+
} else if (q) {
|
|
802
|
+
results.sort((a, b) => {
|
|
803
|
+
const aExact = a.name.toLowerCase() === q ? 1 : 0;
|
|
804
|
+
const bExact = b.name.toLowerCase() === q ? 1 : 0;
|
|
805
|
+
if (aExact !== bExact) return bExact - aExact;
|
|
806
|
+
return b.publishedAt - a.publishedAt;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return results.slice(0, limit).map((s) => {
|
|
810
|
+
const local = installedMap.get(s.name);
|
|
811
|
+
return local ? { ...s, installed: true, path: local.path, enabled: local.enabled } : s;
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
async function handleSkillsRoutes(req, res, url, context) {
|
|
815
|
+
const { appServer, readJsonBody: readJsonBody2 } = context;
|
|
816
|
+
if (req.method === "GET" && url.pathname === "/codex-api/skills-hub") {
|
|
817
|
+
try {
|
|
818
|
+
const q = url.searchParams.get("q") || "";
|
|
819
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 1), 200);
|
|
820
|
+
const sort = url.searchParams.get("sort") || "date";
|
|
821
|
+
const allEntries = await fetchSkillsTree();
|
|
822
|
+
const installedMap = await scanInstalledSkillsFromDisk();
|
|
823
|
+
try {
|
|
824
|
+
const result = await appServer.rpc("skills/list", {});
|
|
825
|
+
for (const entry of result.data ?? []) {
|
|
826
|
+
for (const skill of entry.skills ?? []) {
|
|
827
|
+
if (skill.name) {
|
|
828
|
+
installedMap.set(skill.name, { name: skill.name, path: skill.path ?? "", enabled: skill.enabled !== false });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
} catch {
|
|
833
|
+
}
|
|
834
|
+
const installedHubEntries = allEntries.filter((e) => installedMap.has(e.name));
|
|
835
|
+
await fetchMetaBatch(installedHubEntries);
|
|
836
|
+
const installed = [];
|
|
837
|
+
for (const [, info] of installedMap) {
|
|
838
|
+
const hubEntry = allEntries.find((e) => e.name === info.name);
|
|
839
|
+
const base = hubEntry ? buildHubEntry(hubEntry) : {
|
|
840
|
+
name: info.name,
|
|
841
|
+
owner: "local",
|
|
842
|
+
description: "",
|
|
843
|
+
displayName: "",
|
|
844
|
+
publishedAt: 0,
|
|
845
|
+
avatarUrl: "",
|
|
846
|
+
url: "",
|
|
847
|
+
installed: false
|
|
848
|
+
};
|
|
849
|
+
installed.push({ ...base, installed: true, path: info.path, enabled: info.enabled });
|
|
850
|
+
}
|
|
851
|
+
const results = await searchSkillsHub(allEntries, q, limit, sort, installedMap);
|
|
852
|
+
setJson(res, 200, { data: results, installed, total: allEntries.length });
|
|
853
|
+
} catch (error) {
|
|
854
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to fetch skills hub") });
|
|
855
|
+
}
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
if (req.method === "GET" && url.pathname === "/codex-api/skills-sync/status") {
|
|
859
|
+
const state = await readSkillsSyncState();
|
|
860
|
+
setJson(res, 200, {
|
|
861
|
+
data: {
|
|
862
|
+
loggedIn: Boolean(state.githubToken),
|
|
863
|
+
githubUsername: state.githubUsername ?? "",
|
|
864
|
+
repoOwner: state.repoOwner ?? "",
|
|
865
|
+
repoName: state.repoName ?? "",
|
|
866
|
+
configured: Boolean(state.githubToken && state.repoOwner && state.repoName),
|
|
867
|
+
startup: {
|
|
868
|
+
inProgress: startupSyncStatus.inProgress,
|
|
869
|
+
mode: startupSyncStatus.mode,
|
|
870
|
+
branch: startupSyncStatus.branch,
|
|
871
|
+
lastAction: startupSyncStatus.lastAction,
|
|
872
|
+
lastRunAtIso: startupSyncStatus.lastRunAtIso,
|
|
873
|
+
lastSuccessAtIso: startupSyncStatus.lastSuccessAtIso,
|
|
874
|
+
lastError: startupSyncStatus.lastError
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/start-login") {
|
|
881
|
+
try {
|
|
882
|
+
const started = await startGithubDeviceLogin();
|
|
883
|
+
setJson(res, 200, { data: started });
|
|
884
|
+
} catch (error) {
|
|
885
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to start GitHub login") });
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/token-login") {
|
|
890
|
+
try {
|
|
891
|
+
const payload = asRecord(await readJsonBody2(req));
|
|
892
|
+
const token = typeof payload?.token === "string" ? payload.token.trim() : "";
|
|
893
|
+
if (!token) {
|
|
894
|
+
setJson(res, 400, { error: "Missing GitHub token" });
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
const username = await resolveGithubUsername(token);
|
|
898
|
+
await finalizeGithubLoginAndSync(token, username, appServer);
|
|
899
|
+
setJson(res, 200, { ok: true, data: { githubUsername: username } });
|
|
900
|
+
} catch (error) {
|
|
901
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to login with GitHub token") });
|
|
902
|
+
}
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/logout") {
|
|
906
|
+
try {
|
|
907
|
+
const state = await readSkillsSyncState();
|
|
908
|
+
await writeSkillsSyncState({
|
|
909
|
+
...state,
|
|
910
|
+
githubToken: void 0,
|
|
911
|
+
githubUsername: void 0,
|
|
912
|
+
repoOwner: void 0,
|
|
913
|
+
repoName: void 0
|
|
914
|
+
});
|
|
915
|
+
setJson(res, 200, { ok: true });
|
|
916
|
+
} catch (error) {
|
|
917
|
+
setJson(res, 500, { error: getErrorMessage(error, "Failed to logout GitHub") });
|
|
918
|
+
}
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/complete-login") {
|
|
922
|
+
try {
|
|
923
|
+
const payload = asRecord(await readJsonBody2(req));
|
|
924
|
+
const deviceCode = typeof payload?.deviceCode === "string" ? payload.deviceCode : "";
|
|
925
|
+
if (!deviceCode) {
|
|
926
|
+
setJson(res, 400, { error: "Missing deviceCode" });
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
const result = await completeGithubDeviceLogin(deviceCode);
|
|
930
|
+
if (!result.token) {
|
|
931
|
+
setJson(res, 200, { ok: false, pending: result.error === "authorization_pending", error: result.error || "login_failed" });
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
const token = result.token;
|
|
935
|
+
const username = await resolveGithubUsername(token);
|
|
936
|
+
await finalizeGithubLoginAndSync(token, username, appServer);
|
|
937
|
+
setJson(res, 200, { ok: true, data: { githubUsername: username } });
|
|
938
|
+
} catch (error) {
|
|
939
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to complete GitHub login") });
|
|
940
|
+
}
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/push") {
|
|
944
|
+
try {
|
|
945
|
+
const state = await readSkillsSyncState();
|
|
946
|
+
if (!state.githubToken || !state.repoOwner || !state.repoName) {
|
|
947
|
+
setJson(res, 400, { error: "Skills sync is not configured yet" });
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) {
|
|
951
|
+
setJson(res, 400, { error: "Refusing to push to upstream repository" });
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
const local = await collectLocalSyncedSkills(appServer);
|
|
955
|
+
const installedMap = await scanInstalledSkillsFromDisk();
|
|
956
|
+
await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local);
|
|
957
|
+
await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap);
|
|
958
|
+
setJson(res, 200, { ok: true, data: { synced: local.length } });
|
|
959
|
+
} catch (error) {
|
|
960
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to push synced skills") });
|
|
961
|
+
}
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/pull") {
|
|
965
|
+
try {
|
|
966
|
+
const state = await readSkillsSyncState();
|
|
967
|
+
if (!state.githubToken || !state.repoOwner || !state.repoName) {
|
|
968
|
+
await bootstrapSkillsFromUpstreamIntoLocal();
|
|
969
|
+
try {
|
|
970
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
971
|
+
} catch {
|
|
972
|
+
}
|
|
973
|
+
setJson(res, 200, { ok: true, data: { synced: 0, source: "upstream" } });
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
const remote = await readRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName);
|
|
977
|
+
const tree = await fetchSkillsTree();
|
|
978
|
+
const uniqueOwnerByName = /* @__PURE__ */ new Map();
|
|
979
|
+
const ambiguousNames = /* @__PURE__ */ new Set();
|
|
980
|
+
for (const entry of tree) {
|
|
981
|
+
if (ambiguousNames.has(entry.name)) continue;
|
|
982
|
+
const existingOwner = uniqueOwnerByName.get(entry.name);
|
|
983
|
+
if (!existingOwner) {
|
|
984
|
+
uniqueOwnerByName.set(entry.name, entry.owner);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (existingOwner !== entry.owner) {
|
|
988
|
+
uniqueOwnerByName.delete(entry.name);
|
|
989
|
+
ambiguousNames.add(entry.name);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
const localDir = await detectUserSkillsDir(appServer);
|
|
993
|
+
await pullInstalledSkillsFolderFromRepo(state.githubToken, state.repoOwner, state.repoName);
|
|
994
|
+
const installerScript = "/Users/igor/.cursor/skills/.system/skill-installer/scripts/install-skill-from-github.py";
|
|
995
|
+
const localSkills = await scanInstalledSkillsFromDisk();
|
|
996
|
+
for (const skill of remote) {
|
|
997
|
+
const owner = skill.owner || uniqueOwnerByName.get(skill.name) || "";
|
|
998
|
+
if (!owner) continue;
|
|
999
|
+
if (!localSkills.has(skill.name)) {
|
|
1000
|
+
await runCommand("python3", [
|
|
1001
|
+
installerScript,
|
|
1002
|
+
"--repo",
|
|
1003
|
+
`${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}`,
|
|
1004
|
+
"--path",
|
|
1005
|
+
`skills/${owner}/${skill.name}`,
|
|
1006
|
+
"--dest",
|
|
1007
|
+
localDir,
|
|
1008
|
+
"--method",
|
|
1009
|
+
"git"
|
|
1010
|
+
]);
|
|
1011
|
+
}
|
|
1012
|
+
const skillPath = join(localDir, skill.name);
|
|
1013
|
+
await appServer.rpc("skills/config/write", { path: skillPath, enabled: skill.enabled });
|
|
1014
|
+
}
|
|
1015
|
+
const remoteNames = new Set(remote.map((row) => row.name));
|
|
1016
|
+
for (const [name, localInfo] of localSkills.entries()) {
|
|
1017
|
+
if (!remoteNames.has(name)) {
|
|
1018
|
+
await rm(localInfo.path.replace(/\/SKILL\.md$/, ""), { recursive: true, force: true });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
const nextOwners = {};
|
|
1022
|
+
for (const item of remote) {
|
|
1023
|
+
const owner = item.owner || uniqueOwnerByName.get(item.name) || "";
|
|
1024
|
+
if (owner) nextOwners[item.name] = owner;
|
|
1025
|
+
}
|
|
1026
|
+
await writeSkillsSyncState({ ...state, installedOwners: nextOwners });
|
|
1027
|
+
try {
|
|
1028
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
setJson(res, 200, { ok: true, data: { synced: remote.length } });
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to pull synced skills") });
|
|
1034
|
+
}
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
if (req.method === "GET" && url.pathname === "/codex-api/skills-hub/readme") {
|
|
1038
|
+
try {
|
|
1039
|
+
const owner = url.searchParams.get("owner") || "";
|
|
1040
|
+
const name = url.searchParams.get("name") || "";
|
|
1041
|
+
if (!owner || !name) {
|
|
1042
|
+
setJson(res, 400, { error: "Missing owner or name" });
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
const rawUrl = `https://raw.githubusercontent.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/main/skills/${owner}/${name}/SKILL.md`;
|
|
1046
|
+
const resp = await fetch(rawUrl);
|
|
1047
|
+
if (!resp.ok) throw new Error(`Failed to fetch SKILL.md: ${resp.status}`);
|
|
1048
|
+
const content = await resp.text();
|
|
1049
|
+
setJson(res, 200, { content });
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to fetch SKILL.md") });
|
|
1052
|
+
}
|
|
1053
|
+
return true;
|
|
1054
|
+
}
|
|
1055
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-hub/install") {
|
|
1056
|
+
try {
|
|
1057
|
+
const payload = asRecord(await readJsonBody2(req));
|
|
1058
|
+
const owner = typeof payload?.owner === "string" ? payload.owner : "";
|
|
1059
|
+
const name = typeof payload?.name === "string" ? payload.name : "";
|
|
1060
|
+
if (!owner || !name) {
|
|
1061
|
+
setJson(res, 400, { error: "Missing owner or name" });
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
const installerScript = "/Users/igor/.cursor/skills/.system/skill-installer/scripts/install-skill-from-github.py";
|
|
1065
|
+
const installDest = await detectUserSkillsDir(appServer);
|
|
1066
|
+
await runCommand("python3", [
|
|
1067
|
+
installerScript,
|
|
1068
|
+
"--repo",
|
|
1069
|
+
`${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}`,
|
|
1070
|
+
"--path",
|
|
1071
|
+
`skills/${owner}/${name}`,
|
|
1072
|
+
"--dest",
|
|
1073
|
+
installDest,
|
|
1074
|
+
"--method",
|
|
1075
|
+
"git"
|
|
1076
|
+
]);
|
|
1077
|
+
const skillDir = join(installDest, name);
|
|
1078
|
+
await ensureInstalledSkillIsValid(appServer, skillDir);
|
|
1079
|
+
const syncState = await readSkillsSyncState();
|
|
1080
|
+
const nextOwners = { ...syncState.installedOwners ?? {}, [name]: owner };
|
|
1081
|
+
await writeSkillsSyncState({ ...syncState, installedOwners: nextOwners });
|
|
1082
|
+
await autoPushSyncedSkills(appServer);
|
|
1083
|
+
setJson(res, 200, { ok: true, path: skillDir });
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to install skill") });
|
|
1086
|
+
}
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
if (req.method === "POST" && url.pathname === "/codex-api/skills-hub/uninstall") {
|
|
1090
|
+
try {
|
|
1091
|
+
const payload = asRecord(await readJsonBody2(req));
|
|
1092
|
+
const name = typeof payload?.name === "string" ? payload.name : "";
|
|
1093
|
+
const path = typeof payload?.path === "string" ? payload.path : "";
|
|
1094
|
+
const target = path || (name ? join(getSkillsInstallDir(), name) : "");
|
|
1095
|
+
if (!target) {
|
|
1096
|
+
setJson(res, 400, { error: "Missing name or path" });
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
await rm(target, { recursive: true, force: true });
|
|
1100
|
+
if (name) {
|
|
1101
|
+
const syncState = await readSkillsSyncState();
|
|
1102
|
+
const nextOwners = { ...syncState.installedOwners ?? {} };
|
|
1103
|
+
delete nextOwners[name];
|
|
1104
|
+
await writeSkillsSyncState({ ...syncState, installedOwners: nextOwners });
|
|
1105
|
+
}
|
|
1106
|
+
await autoPushSyncedSkills(appServer);
|
|
1107
|
+
try {
|
|
1108
|
+
await appServer.rpc("skills/list", { forceReload: true });
|
|
1109
|
+
} catch {
|
|
1110
|
+
}
|
|
1111
|
+
setJson(res, 200, { ok: true, deletedPath: target });
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
setJson(res, 502, { error: getErrorMessage(error, "Failed to uninstall skill") });
|
|
1114
|
+
}
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/server/codexAppServerBridge.ts
|
|
1121
|
+
function asRecord2(value) {
|
|
1122
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1123
|
+
}
|
|
1124
|
+
function getErrorMessage2(payload, fallback) {
|
|
1125
|
+
if (payload instanceof Error && payload.message.trim().length > 0) {
|
|
1126
|
+
return payload.message;
|
|
1127
|
+
}
|
|
1128
|
+
const record = asRecord2(payload);
|
|
1129
|
+
if (!record) return fallback;
|
|
1130
|
+
const error = record.error;
|
|
1131
|
+
if (typeof error === "string" && error.length > 0) return error;
|
|
1132
|
+
const nestedError = asRecord2(error);
|
|
1133
|
+
if (nestedError && typeof nestedError.message === "string" && nestedError.message.length > 0) {
|
|
1134
|
+
return nestedError.message;
|
|
1135
|
+
}
|
|
1136
|
+
return fallback;
|
|
1137
|
+
}
|
|
1138
|
+
function setJson2(res, statusCode, payload) {
|
|
1139
|
+
res.statusCode = statusCode;
|
|
1140
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1141
|
+
res.end(JSON.stringify(payload));
|
|
1142
|
+
}
|
|
1143
|
+
function extractThreadMessageText(threadReadPayload) {
|
|
1144
|
+
const payload = asRecord2(threadReadPayload);
|
|
1145
|
+
const thread = asRecord2(payload?.thread);
|
|
1146
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
|
|
1147
|
+
const parts = [];
|
|
1148
|
+
for (const turn of turns) {
|
|
1149
|
+
const turnRecord = asRecord2(turn);
|
|
1150
|
+
const items = Array.isArray(turnRecord?.items) ? turnRecord.items : [];
|
|
1151
|
+
for (const item of items) {
|
|
1152
|
+
const itemRecord = asRecord2(item);
|
|
1153
|
+
const type = typeof itemRecord?.type === "string" ? itemRecord.type : "";
|
|
1154
|
+
if (type === "agentMessage" && typeof itemRecord?.text === "string" && itemRecord.text.trim().length > 0) {
|
|
1155
|
+
parts.push(itemRecord.text.trim());
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (type === "userMessage") {
|
|
1159
|
+
const content = Array.isArray(itemRecord?.content) ? itemRecord.content : [];
|
|
1160
|
+
for (const block of content) {
|
|
1161
|
+
const blockRecord = asRecord2(block);
|
|
1162
|
+
if (blockRecord?.type === "text" && typeof blockRecord.text === "string" && blockRecord.text.trim().length > 0) {
|
|
1163
|
+
parts.push(blockRecord.text.trim());
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (type === "commandExecution") {
|
|
1169
|
+
const command = typeof itemRecord?.command === "string" ? itemRecord.command.trim() : "";
|
|
1170
|
+
const output = typeof itemRecord?.aggregatedOutput === "string" ? itemRecord.aggregatedOutput.trim() : "";
|
|
1171
|
+
if (command) parts.push(command);
|
|
1172
|
+
if (output) parts.push(output);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return parts.join("\n").trim();
|
|
1177
|
+
}
|
|
1178
|
+
function isExactPhraseMatch(query, doc) {
|
|
1179
|
+
const q = query.trim().toLowerCase();
|
|
1180
|
+
if (!q) return false;
|
|
1181
|
+
return doc.title.toLowerCase().includes(q) || doc.preview.toLowerCase().includes(q) || doc.messageText.toLowerCase().includes(q);
|
|
1182
|
+
}
|
|
1183
|
+
function scoreFileCandidate(path, query) {
|
|
1184
|
+
if (!query) return 0;
|
|
1185
|
+
const lowerPath = path.toLowerCase();
|
|
1186
|
+
const lowerQuery = query.toLowerCase();
|
|
1187
|
+
const baseName = lowerPath.slice(lowerPath.lastIndexOf("/") + 1);
|
|
1188
|
+
if (baseName === lowerQuery) return 0;
|
|
1189
|
+
if (baseName.startsWith(lowerQuery)) return 1;
|
|
1190
|
+
if (baseName.includes(lowerQuery)) return 2;
|
|
1191
|
+
if (lowerPath.includes(`/${lowerQuery}`)) return 3;
|
|
1192
|
+
if (lowerPath.includes(lowerQuery)) return 4;
|
|
1193
|
+
return 10;
|
|
1194
|
+
}
|
|
1195
|
+
async function listFilesWithRipgrep(cwd) {
|
|
1196
|
+
return await new Promise((resolve2, reject) => {
|
|
1197
|
+
const proc = spawn2("rg", ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules"], {
|
|
1198
|
+
cwd,
|
|
1199
|
+
env: process.env,
|
|
1200
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1201
|
+
});
|
|
1202
|
+
let stdout = "";
|
|
1203
|
+
let stderr = "";
|
|
1204
|
+
proc.stdout.on("data", (chunk) => {
|
|
1205
|
+
stdout += chunk.toString();
|
|
1206
|
+
});
|
|
1207
|
+
proc.stderr.on("data", (chunk) => {
|
|
1208
|
+
stderr += chunk.toString();
|
|
1209
|
+
});
|
|
1210
|
+
proc.on("error", reject);
|
|
1211
|
+
proc.on("close", (code) => {
|
|
1212
|
+
if (code === 0) {
|
|
1213
|
+
const rows = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1214
|
+
resolve2(rows);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1218
|
+
reject(new Error(details || "rg --files failed"));
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
function getCodexHomeDir2() {
|
|
1223
|
+
const codexHome = process.env.CODEX_HOME?.trim();
|
|
1224
|
+
return codexHome && codexHome.length > 0 ? codexHome : join2(homedir2(), ".codex");
|
|
1225
|
+
}
|
|
1226
|
+
async function runCommand2(command, args, options = {}) {
|
|
1227
|
+
await new Promise((resolve2, reject) => {
|
|
1228
|
+
const proc = spawn2(command, args, {
|
|
1229
|
+
cwd: options.cwd,
|
|
1230
|
+
env: process.env,
|
|
1231
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1232
|
+
});
|
|
1233
|
+
let stdout = "";
|
|
1234
|
+
let stderr = "";
|
|
1235
|
+
proc.stdout.on("data", (chunk) => {
|
|
1236
|
+
stdout += chunk.toString();
|
|
1237
|
+
});
|
|
1238
|
+
proc.stderr.on("data", (chunk) => {
|
|
1239
|
+
stderr += chunk.toString();
|
|
1240
|
+
});
|
|
1241
|
+
proc.on("error", reject);
|
|
1242
|
+
proc.on("close", (code) => {
|
|
1243
|
+
if (code === 0) {
|
|
1244
|
+
resolve2();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1248
|
+
const suffix = details.length > 0 ? `: ${details}` : "";
|
|
1249
|
+
reject(new Error(`Command failed (${command} ${args.join(" ")})${suffix}`));
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
function isMissingHeadError(error) {
|
|
1254
|
+
const message = getErrorMessage2(error, "").toLowerCase();
|
|
1255
|
+
return message.includes("not a valid object name: 'head'") || message.includes("not a valid object name: head") || message.includes("invalid reference: head");
|
|
1256
|
+
}
|
|
1257
|
+
function isNotGitRepositoryError(error) {
|
|
1258
|
+
const message = getErrorMessage2(error, "").toLowerCase();
|
|
1259
|
+
return message.includes("not a git repository") || message.includes("fatal: not a git repository");
|
|
1260
|
+
}
|
|
1261
|
+
async function ensureRepoHasInitialCommit(repoRoot) {
|
|
1262
|
+
const agentsPath = join2(repoRoot, "AGENTS.md");
|
|
1263
|
+
try {
|
|
1264
|
+
await stat2(agentsPath);
|
|
1265
|
+
} catch {
|
|
1266
|
+
await writeFile2(agentsPath, "", "utf8");
|
|
1267
|
+
}
|
|
1268
|
+
await runCommand2("git", ["add", "AGENTS.md"], { cwd: repoRoot });
|
|
1269
|
+
await runCommand2(
|
|
1270
|
+
"git",
|
|
1271
|
+
["-c", "user.name=Codex", "-c", "user.email=codex@local", "commit", "-m", "Initialize repository for worktree support"],
|
|
1272
|
+
{ cwd: repoRoot }
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
async function runCommandCapture(command, args, options = {}) {
|
|
1276
|
+
return await new Promise((resolve2, reject) => {
|
|
1277
|
+
const proc = spawn2(command, args, {
|
|
1278
|
+
cwd: options.cwd,
|
|
1279
|
+
env: process.env,
|
|
1280
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1281
|
+
});
|
|
1282
|
+
let stdout = "";
|
|
1283
|
+
let stderr = "";
|
|
1284
|
+
proc.stdout.on("data", (chunk) => {
|
|
1285
|
+
stdout += chunk.toString();
|
|
1286
|
+
});
|
|
1287
|
+
proc.stderr.on("data", (chunk) => {
|
|
1288
|
+
stderr += chunk.toString();
|
|
1289
|
+
});
|
|
1290
|
+
proc.on("error", reject);
|
|
1291
|
+
proc.on("close", (code) => {
|
|
1292
|
+
if (code === 0) {
|
|
1293
|
+
resolve2(stdout.trim());
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
1297
|
+
const suffix = details.length > 0 ? `: ${details}` : "";
|
|
1298
|
+
reject(new Error(`Command failed (${command} ${args.join(" ")})${suffix}`));
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
function normalizeStringArray(value) {
|
|
1303
|
+
if (!Array.isArray(value)) return [];
|
|
1304
|
+
const normalized = [];
|
|
1305
|
+
for (const item of value) {
|
|
1306
|
+
if (typeof item === "string" && item.length > 0 && !normalized.includes(item)) {
|
|
1307
|
+
normalized.push(item);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return normalized;
|
|
1311
|
+
}
|
|
1312
|
+
function normalizeStringRecord(value) {
|
|
1313
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
1314
|
+
const next = {};
|
|
1315
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1316
|
+
if (typeof key === "string" && key.length > 0 && typeof item === "string") {
|
|
1317
|
+
next[key] = item;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return next;
|
|
1321
|
+
}
|
|
1322
|
+
function getCodexAuthPath() {
|
|
1323
|
+
return join2(getCodexHomeDir2(), "auth.json");
|
|
1324
|
+
}
|
|
1325
|
+
async function readCodexAuth() {
|
|
1326
|
+
try {
|
|
1327
|
+
const raw = await readFile2(getCodexAuthPath(), "utf8");
|
|
1328
|
+
const auth = JSON.parse(raw);
|
|
1329
|
+
const token = auth.tokens?.access_token;
|
|
1330
|
+
if (!token) return null;
|
|
1331
|
+
return { accessToken: token, accountId: auth.tokens?.account_id ?? void 0 };
|
|
1332
|
+
} catch {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function getCodexGlobalStatePath() {
|
|
1337
|
+
return join2(getCodexHomeDir2(), ".codex-global-state.json");
|
|
1338
|
+
}
|
|
1339
|
+
var MAX_THREAD_TITLES = 500;
|
|
1340
|
+
function normalizeThreadTitleCache(value) {
|
|
1341
|
+
const record = asRecord2(value);
|
|
1342
|
+
if (!record) return { titles: {}, order: [] };
|
|
1343
|
+
const rawTitles = asRecord2(record.titles);
|
|
1344
|
+
const titles = {};
|
|
1345
|
+
if (rawTitles) {
|
|
1346
|
+
for (const [k, v] of Object.entries(rawTitles)) {
|
|
1347
|
+
if (typeof v === "string" && v.length > 0) titles[k] = v;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const order = normalizeStringArray(record.order);
|
|
1351
|
+
return { titles, order };
|
|
1352
|
+
}
|
|
1353
|
+
function updateThreadTitleCache(cache, id, title) {
|
|
1354
|
+
const titles = { ...cache.titles, [id]: title };
|
|
1355
|
+
const order = [id, ...cache.order.filter((o) => o !== id)];
|
|
1356
|
+
while (order.length > MAX_THREAD_TITLES) {
|
|
1357
|
+
const removed = order.pop();
|
|
1358
|
+
if (removed) delete titles[removed];
|
|
1359
|
+
}
|
|
1360
|
+
return { titles, order };
|
|
1361
|
+
}
|
|
1362
|
+
function removeFromThreadTitleCache(cache, id) {
|
|
1363
|
+
const { [id]: _, ...titles } = cache.titles;
|
|
1364
|
+
return { titles, order: cache.order.filter((o) => o !== id) };
|
|
1365
|
+
}
|
|
1366
|
+
async function readThreadTitleCache() {
|
|
1367
|
+
const statePath = getCodexGlobalStatePath();
|
|
1368
|
+
try {
|
|
1369
|
+
const raw = await readFile2(statePath, "utf8");
|
|
1370
|
+
const payload = asRecord2(JSON.parse(raw)) ?? {};
|
|
1371
|
+
return normalizeThreadTitleCache(payload["thread-titles"]);
|
|
1372
|
+
} catch {
|
|
1373
|
+
return { titles: {}, order: [] };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
async function writeThreadTitleCache(cache) {
|
|
1377
|
+
const statePath = getCodexGlobalStatePath();
|
|
1378
|
+
let payload = {};
|
|
1379
|
+
try {
|
|
1380
|
+
const raw = await readFile2(statePath, "utf8");
|
|
1381
|
+
payload = asRecord2(JSON.parse(raw)) ?? {};
|
|
1382
|
+
} catch {
|
|
1383
|
+
payload = {};
|
|
1384
|
+
}
|
|
1385
|
+
payload["thread-titles"] = cache;
|
|
1386
|
+
await writeFile2(statePath, JSON.stringify(payload), "utf8");
|
|
1387
|
+
}
|
|
1388
|
+
async function readWorkspaceRootsState() {
|
|
1389
|
+
const statePath = getCodexGlobalStatePath();
|
|
1390
|
+
let payload = {};
|
|
1391
|
+
try {
|
|
1392
|
+
const raw = await readFile2(statePath, "utf8");
|
|
1393
|
+
const parsed = JSON.parse(raw);
|
|
1394
|
+
payload = asRecord2(parsed) ?? {};
|
|
1395
|
+
} catch {
|
|
1396
|
+
payload = {};
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
order: normalizeStringArray(payload["electron-saved-workspace-roots"]),
|
|
1400
|
+
labels: normalizeStringRecord(payload["electron-workspace-root-labels"]),
|
|
1401
|
+
active: normalizeStringArray(payload["active-workspace-roots"])
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
async function writeWorkspaceRootsState(nextState) {
|
|
1405
|
+
const statePath = getCodexGlobalStatePath();
|
|
1406
|
+
let payload = {};
|
|
1407
|
+
try {
|
|
1408
|
+
const raw = await readFile2(statePath, "utf8");
|
|
1409
|
+
payload = asRecord2(JSON.parse(raw)) ?? {};
|
|
1410
|
+
} catch {
|
|
1411
|
+
payload = {};
|
|
1412
|
+
}
|
|
1413
|
+
payload["electron-saved-workspace-roots"] = normalizeStringArray(nextState.order);
|
|
1414
|
+
payload["electron-workspace-root-labels"] = normalizeStringRecord(nextState.labels);
|
|
1415
|
+
payload["active-workspace-roots"] = normalizeStringArray(nextState.active);
|
|
1416
|
+
await writeFile2(statePath, JSON.stringify(payload), "utf8");
|
|
1417
|
+
}
|
|
1418
|
+
async function readJsonBody(req) {
|
|
1419
|
+
const raw = await readRawBody(req);
|
|
1420
|
+
if (raw.length === 0) return null;
|
|
1421
|
+
const text = raw.toString("utf8").trim();
|
|
1422
|
+
if (text.length === 0) return null;
|
|
1423
|
+
return JSON.parse(text);
|
|
1424
|
+
}
|
|
1425
|
+
async function readRawBody(req) {
|
|
1426
|
+
const chunks = [];
|
|
1427
|
+
for await (const chunk of req) {
|
|
1428
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1429
|
+
}
|
|
1430
|
+
return Buffer.concat(chunks);
|
|
1431
|
+
}
|
|
1432
|
+
function bufferIndexOf(buf, needle, start = 0) {
|
|
1433
|
+
for (let i = start; i <= buf.length - needle.length; i++) {
|
|
1434
|
+
let match = true;
|
|
1435
|
+
for (let j = 0; j < needle.length; j++) {
|
|
1436
|
+
if (buf[i + j] !== needle[j]) {
|
|
1437
|
+
match = false;
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (match) return i;
|
|
1442
|
+
}
|
|
1443
|
+
return -1;
|
|
1444
|
+
}
|
|
1445
|
+
function handleFileUpload(req, res) {
|
|
1446
|
+
const chunks = [];
|
|
1447
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1448
|
+
req.on("end", async () => {
|
|
1449
|
+
try {
|
|
1450
|
+
const body = Buffer.concat(chunks);
|
|
1451
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
1452
|
+
const boundaryMatch = contentType.match(/boundary=(.+)/i);
|
|
1453
|
+
if (!boundaryMatch) {
|
|
1454
|
+
setJson2(res, 400, { error: "Missing multipart boundary" });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const boundary = boundaryMatch[1];
|
|
1458
|
+
const boundaryBuf = Buffer.from(`--${boundary}`);
|
|
1459
|
+
const parts = [];
|
|
1460
|
+
let searchStart = 0;
|
|
1461
|
+
while (searchStart < body.length) {
|
|
1462
|
+
const idx = body.indexOf(boundaryBuf, searchStart);
|
|
1463
|
+
if (idx < 0) break;
|
|
1464
|
+
if (searchStart > 0) parts.push(body.subarray(searchStart, idx));
|
|
1465
|
+
searchStart = idx + boundaryBuf.length;
|
|
1466
|
+
if (body[searchStart] === 13 && body[searchStart + 1] === 10) searchStart += 2;
|
|
1467
|
+
}
|
|
1468
|
+
let fileName = "uploaded-file";
|
|
1469
|
+
let fileData = null;
|
|
1470
|
+
const headerSep = Buffer.from("\r\n\r\n");
|
|
1471
|
+
for (const part of parts) {
|
|
1472
|
+
const headerEnd = bufferIndexOf(part, headerSep);
|
|
1473
|
+
if (headerEnd < 0) continue;
|
|
1474
|
+
const headers = part.subarray(0, headerEnd).toString("utf8");
|
|
1475
|
+
const fnMatch = headers.match(/filename="([^"]+)"/i);
|
|
1476
|
+
if (!fnMatch) continue;
|
|
1477
|
+
fileName = fnMatch[1].replace(/[/\\]/g, "_");
|
|
1478
|
+
let end = part.length;
|
|
1479
|
+
if (end >= 2 && part[end - 2] === 13 && part[end - 1] === 10) end -= 2;
|
|
1480
|
+
fileData = part.subarray(headerEnd + 4, end);
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
if (!fileData) {
|
|
1484
|
+
setJson2(res, 400, { error: "No file in request" });
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const uploadDir = join2(tmpdir2(), "codex-web-uploads");
|
|
1488
|
+
await mkdir2(uploadDir, { recursive: true });
|
|
1489
|
+
const destDir = await mkdtemp2(join2(uploadDir, "f-"));
|
|
1490
|
+
const destPath = join2(destDir, fileName);
|
|
1491
|
+
await writeFile2(destPath, fileData);
|
|
1492
|
+
setJson2(res, 200, { path: destPath });
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
setJson2(res, 500, { error: getErrorMessage2(err, "Upload failed") });
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
req.on("error", (err) => {
|
|
1498
|
+
setJson2(res, 500, { error: getErrorMessage2(err, "Upload stream error") });
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
async function proxyTranscribe(body, contentType, authToken, accountId) {
|
|
1502
|
+
const headers = {
|
|
1503
|
+
"Content-Type": contentType,
|
|
1504
|
+
"Content-Length": body.length,
|
|
1505
|
+
Authorization: `Bearer ${authToken}`,
|
|
1506
|
+
originator: "Codex Desktop",
|
|
1507
|
+
"User-Agent": `Codex Desktop/0.1.0 (${process.platform}; ${process.arch})`
|
|
1508
|
+
};
|
|
1509
|
+
if (accountId) {
|
|
1510
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
1511
|
+
}
|
|
1512
|
+
return new Promise((resolve2, reject) => {
|
|
1513
|
+
const req = httpsRequest(
|
|
1514
|
+
"https://chatgpt.com/backend-api/transcribe",
|
|
1515
|
+
{ method: "POST", headers },
|
|
1516
|
+
(res) => {
|
|
1517
|
+
const chunks = [];
|
|
1518
|
+
res.on("data", (c) => chunks.push(c));
|
|
1519
|
+
res.on("end", () => resolve2({ status: res.statusCode ?? 500, body: Buffer.concat(chunks).toString("utf8") }));
|
|
1520
|
+
res.on("error", reject);
|
|
1521
|
+
}
|
|
1522
|
+
);
|
|
1523
|
+
req.on("error", reject);
|
|
1524
|
+
req.write(body);
|
|
1525
|
+
req.end();
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
var AppServerProcess = class {
|
|
1529
|
+
constructor() {
|
|
1530
|
+
this.process = null;
|
|
1531
|
+
this.initialized = false;
|
|
1532
|
+
this.initializePromise = null;
|
|
1533
|
+
this.readBuffer = "";
|
|
1534
|
+
this.nextId = 1;
|
|
1535
|
+
this.stopping = false;
|
|
1536
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
1537
|
+
this.notificationListeners = /* @__PURE__ */ new Set();
|
|
1538
|
+
this.pendingServerRequests = /* @__PURE__ */ new Map();
|
|
1539
|
+
this.appServerArgs = [
|
|
1540
|
+
"app-server",
|
|
1541
|
+
"-c",
|
|
1542
|
+
'approval_policy="never"',
|
|
1543
|
+
"-c",
|
|
1544
|
+
'sandbox_mode="danger-full-access"'
|
|
1545
|
+
];
|
|
1546
|
+
}
|
|
1547
|
+
start() {
|
|
1548
|
+
if (this.process) return;
|
|
1549
|
+
this.stopping = false;
|
|
1550
|
+
const proc = spawn2("codex", this.appServerArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
1551
|
+
this.process = proc;
|
|
1552
|
+
proc.stdout.setEncoding("utf8");
|
|
1553
|
+
proc.stdout.on("data", (chunk) => {
|
|
1554
|
+
this.readBuffer += chunk;
|
|
1555
|
+
let lineEnd = this.readBuffer.indexOf("\n");
|
|
1556
|
+
while (lineEnd !== -1) {
|
|
1557
|
+
const line = this.readBuffer.slice(0, lineEnd).trim();
|
|
1558
|
+
this.readBuffer = this.readBuffer.slice(lineEnd + 1);
|
|
1559
|
+
if (line.length > 0) {
|
|
1560
|
+
this.handleLine(line);
|
|
1561
|
+
}
|
|
1562
|
+
lineEnd = this.readBuffer.indexOf("\n");
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
proc.stderr.setEncoding("utf8");
|
|
1566
|
+
proc.stderr.on("data", () => {
|
|
1567
|
+
});
|
|
1568
|
+
proc.on("exit", () => {
|
|
1569
|
+
const failure = new Error(this.stopping ? "codex app-server stopped" : "codex app-server exited unexpectedly");
|
|
1570
|
+
for (const request of this.pending.values()) {
|
|
1571
|
+
request.reject(failure);
|
|
1572
|
+
}
|
|
1573
|
+
this.pending.clear();
|
|
1574
|
+
this.pendingServerRequests.clear();
|
|
1575
|
+
this.process = null;
|
|
1576
|
+
this.initialized = false;
|
|
1577
|
+
this.initializePromise = null;
|
|
1578
|
+
this.readBuffer = "";
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
sendLine(payload) {
|
|
1582
|
+
if (!this.process) {
|
|
1583
|
+
throw new Error("codex app-server is not running");
|
|
1584
|
+
}
|
|
1585
|
+
this.process.stdin.write(`${JSON.stringify(payload)}
|
|
1586
|
+
`);
|
|
1587
|
+
}
|
|
1588
|
+
handleLine(line) {
|
|
1589
|
+
let message;
|
|
1590
|
+
try {
|
|
1591
|
+
message = JSON.parse(line);
|
|
1592
|
+
} catch {
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
1596
|
+
const pendingRequest = this.pending.get(message.id);
|
|
1597
|
+
this.pending.delete(message.id);
|
|
1598
|
+
if (!pendingRequest) return;
|
|
1599
|
+
if (message.error) {
|
|
1600
|
+
pendingRequest.reject(new Error(message.error.message));
|
|
1601
|
+
} else {
|
|
1602
|
+
pendingRequest.resolve(message.result);
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (typeof message.method === "string" && typeof message.id !== "number") {
|
|
1607
|
+
this.emitNotification({
|
|
1608
|
+
method: message.method,
|
|
1609
|
+
params: message.params ?? null
|
|
1610
|
+
});
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (typeof message.id === "number" && typeof message.method === "string") {
|
|
1614
|
+
this.handleServerRequest(message.id, message.method, message.params ?? null);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
emitNotification(notification) {
|
|
1618
|
+
for (const listener of this.notificationListeners) {
|
|
1619
|
+
listener(notification);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
sendServerRequestReply(requestId, reply) {
|
|
1623
|
+
if (reply.error) {
|
|
1624
|
+
this.sendLine({
|
|
1625
|
+
jsonrpc: "2.0",
|
|
1626
|
+
id: requestId,
|
|
1627
|
+
error: reply.error
|
|
1628
|
+
});
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
this.sendLine({
|
|
1632
|
+
jsonrpc: "2.0",
|
|
1633
|
+
id: requestId,
|
|
1634
|
+
result: reply.result ?? {}
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
resolvePendingServerRequest(requestId, reply) {
|
|
1638
|
+
const pendingRequest = this.pendingServerRequests.get(requestId);
|
|
1639
|
+
if (!pendingRequest) {
|
|
1640
|
+
throw new Error(`No pending server request found for id ${String(requestId)}`);
|
|
1641
|
+
}
|
|
1642
|
+
this.pendingServerRequests.delete(requestId);
|
|
1643
|
+
this.sendServerRequestReply(requestId, reply);
|
|
1644
|
+
const requestParams = asRecord2(pendingRequest.params);
|
|
1645
|
+
const threadId = typeof requestParams?.threadId === "string" && requestParams.threadId.length > 0 ? requestParams.threadId : "";
|
|
1646
|
+
this.emitNotification({
|
|
1647
|
+
method: "server/request/resolved",
|
|
1648
|
+
params: {
|
|
1649
|
+
id: requestId,
|
|
1650
|
+
method: pendingRequest.method,
|
|
1651
|
+
threadId,
|
|
1652
|
+
mode: "manual",
|
|
1653
|
+
resolvedAtIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
handleServerRequest(requestId, method, params) {
|
|
1658
|
+
const pendingRequest = {
|
|
1659
|
+
id: requestId,
|
|
1660
|
+
method,
|
|
1661
|
+
params,
|
|
1662
|
+
receivedAtIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
1663
|
+
};
|
|
1664
|
+
this.pendingServerRequests.set(requestId, pendingRequest);
|
|
1665
|
+
this.emitNotification({
|
|
1666
|
+
method: "server/request",
|
|
1667
|
+
params: pendingRequest
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
async call(method, params) {
|
|
1671
|
+
this.start();
|
|
1672
|
+
const id = this.nextId++;
|
|
1673
|
+
return new Promise((resolve2, reject) => {
|
|
1674
|
+
this.pending.set(id, { resolve: resolve2, reject });
|
|
1675
|
+
this.sendLine({
|
|
1676
|
+
jsonrpc: "2.0",
|
|
1677
|
+
id,
|
|
1678
|
+
method,
|
|
1679
|
+
params
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
async ensureInitialized() {
|
|
1684
|
+
if (this.initialized) return;
|
|
1685
|
+
if (this.initializePromise) {
|
|
1686
|
+
await this.initializePromise;
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
this.initializePromise = this.call("initialize", {
|
|
1690
|
+
clientInfo: {
|
|
1691
|
+
name: "codex-web-local",
|
|
1692
|
+
version: "0.1.0"
|
|
1693
|
+
},
|
|
1694
|
+
capabilities: {
|
|
1695
|
+
experimentalApi: true
|
|
1696
|
+
}
|
|
1697
|
+
}).then(() => {
|
|
1698
|
+
this.sendLine({
|
|
1699
|
+
jsonrpc: "2.0",
|
|
1700
|
+
method: "initialized"
|
|
1701
|
+
});
|
|
1702
|
+
this.initialized = true;
|
|
1703
|
+
}).finally(() => {
|
|
1704
|
+
this.initializePromise = null;
|
|
1705
|
+
});
|
|
1706
|
+
await this.initializePromise;
|
|
1707
|
+
}
|
|
1708
|
+
async rpc(method, params) {
|
|
1709
|
+
await this.ensureInitialized();
|
|
1710
|
+
return this.call(method, params);
|
|
1711
|
+
}
|
|
1712
|
+
onNotification(listener) {
|
|
1713
|
+
this.notificationListeners.add(listener);
|
|
1714
|
+
return () => {
|
|
1715
|
+
this.notificationListeners.delete(listener);
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
async respondToServerRequest(payload) {
|
|
1719
|
+
await this.ensureInitialized();
|
|
1720
|
+
const body = asRecord2(payload);
|
|
1721
|
+
if (!body) {
|
|
1722
|
+
throw new Error("Invalid response payload: expected object");
|
|
1723
|
+
}
|
|
1724
|
+
const id = body.id;
|
|
1725
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
1726
|
+
throw new Error('Invalid response payload: "id" must be an integer');
|
|
1727
|
+
}
|
|
1728
|
+
const rawError = asRecord2(body.error);
|
|
1729
|
+
if (rawError) {
|
|
1730
|
+
const message = typeof rawError.message === "string" && rawError.message.trim().length > 0 ? rawError.message.trim() : "Server request rejected by client";
|
|
1731
|
+
const code = typeof rawError.code === "number" && Number.isFinite(rawError.code) ? Math.trunc(rawError.code) : -32e3;
|
|
1732
|
+
this.resolvePendingServerRequest(id, { error: { code, message } });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (!("result" in body)) {
|
|
1736
|
+
throw new Error('Invalid response payload: expected "result" or "error"');
|
|
1737
|
+
}
|
|
1738
|
+
this.resolvePendingServerRequest(id, { result: body.result });
|
|
1739
|
+
}
|
|
1740
|
+
listPendingServerRequests() {
|
|
1741
|
+
return Array.from(this.pendingServerRequests.values());
|
|
1742
|
+
}
|
|
1743
|
+
dispose() {
|
|
1744
|
+
if (!this.process) return;
|
|
1745
|
+
const proc = this.process;
|
|
1746
|
+
this.stopping = true;
|
|
1747
|
+
this.process = null;
|
|
1748
|
+
this.initialized = false;
|
|
1749
|
+
this.initializePromise = null;
|
|
1750
|
+
this.readBuffer = "";
|
|
1751
|
+
const failure = new Error("codex app-server stopped");
|
|
1752
|
+
for (const request of this.pending.values()) {
|
|
1753
|
+
request.reject(failure);
|
|
1754
|
+
}
|
|
1755
|
+
this.pending.clear();
|
|
1756
|
+
this.pendingServerRequests.clear();
|
|
1757
|
+
try {
|
|
1758
|
+
proc.stdin.end();
|
|
1759
|
+
} catch {
|
|
1760
|
+
}
|
|
1761
|
+
try {
|
|
1762
|
+
proc.kill("SIGTERM");
|
|
1763
|
+
} catch {
|
|
1764
|
+
}
|
|
1765
|
+
const forceKillTimer = setTimeout(() => {
|
|
1766
|
+
if (!proc.killed) {
|
|
1767
|
+
try {
|
|
1768
|
+
proc.kill("SIGKILL");
|
|
1769
|
+
} catch {
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}, 1500);
|
|
1773
|
+
forceKillTimer.unref();
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
var MethodCatalog = class {
|
|
1777
|
+
constructor() {
|
|
1778
|
+
this.methodCache = null;
|
|
1779
|
+
this.notificationCache = null;
|
|
1780
|
+
}
|
|
1781
|
+
async runGenerateSchemaCommand(outDir) {
|
|
1782
|
+
await new Promise((resolve2, reject) => {
|
|
1783
|
+
const process2 = spawn2("codex", ["app-server", "generate-json-schema", "--out", outDir], {
|
|
1784
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
1785
|
+
});
|
|
1786
|
+
let stderr = "";
|
|
1787
|
+
process2.stderr.setEncoding("utf8");
|
|
1788
|
+
process2.stderr.on("data", (chunk) => {
|
|
1789
|
+
stderr += chunk;
|
|
1790
|
+
});
|
|
1791
|
+
process2.on("error", reject);
|
|
1792
|
+
process2.on("exit", (code) => {
|
|
1793
|
+
if (code === 0) {
|
|
1794
|
+
resolve2();
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
reject(new Error(stderr.trim() || `generate-json-schema exited with code ${String(code)}`));
|
|
1798
|
+
});
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
extractMethodsFromClientRequest(payload) {
|
|
1802
|
+
const root = asRecord2(payload);
|
|
1803
|
+
const oneOf = Array.isArray(root?.oneOf) ? root.oneOf : [];
|
|
1804
|
+
const methods = /* @__PURE__ */ new Set();
|
|
1805
|
+
for (const entry of oneOf) {
|
|
1806
|
+
const row = asRecord2(entry);
|
|
1807
|
+
const properties = asRecord2(row?.properties);
|
|
1808
|
+
const methodDef = asRecord2(properties?.method);
|
|
1809
|
+
const methodEnum = Array.isArray(methodDef?.enum) ? methodDef.enum : [];
|
|
1810
|
+
for (const item of methodEnum) {
|
|
1811
|
+
if (typeof item === "string" && item.length > 0) {
|
|
1812
|
+
methods.add(item);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return Array.from(methods).sort((a, b) => a.localeCompare(b));
|
|
1817
|
+
}
|
|
1818
|
+
extractMethodsFromServerNotification(payload) {
|
|
1819
|
+
const root = asRecord2(payload);
|
|
1820
|
+
const oneOf = Array.isArray(root?.oneOf) ? root.oneOf : [];
|
|
1821
|
+
const methods = /* @__PURE__ */ new Set();
|
|
1822
|
+
for (const entry of oneOf) {
|
|
1823
|
+
const row = asRecord2(entry);
|
|
1824
|
+
const properties = asRecord2(row?.properties);
|
|
1825
|
+
const methodDef = asRecord2(properties?.method);
|
|
1826
|
+
const methodEnum = Array.isArray(methodDef?.enum) ? methodDef.enum : [];
|
|
1827
|
+
for (const item of methodEnum) {
|
|
1828
|
+
if (typeof item === "string" && item.length > 0) {
|
|
1829
|
+
methods.add(item);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return Array.from(methods).sort((a, b) => a.localeCompare(b));
|
|
1834
|
+
}
|
|
1835
|
+
async listMethods() {
|
|
1836
|
+
if (this.methodCache) {
|
|
1837
|
+
return this.methodCache;
|
|
1838
|
+
}
|
|
1839
|
+
const outDir = await mkdtemp2(join2(tmpdir2(), "codex-web-local-schema-"));
|
|
1840
|
+
await this.runGenerateSchemaCommand(outDir);
|
|
1841
|
+
const clientRequestPath = join2(outDir, "ClientRequest.json");
|
|
1842
|
+
const raw = await readFile2(clientRequestPath, "utf8");
|
|
1843
|
+
const parsed = JSON.parse(raw);
|
|
1844
|
+
const methods = this.extractMethodsFromClientRequest(parsed);
|
|
1845
|
+
this.methodCache = methods;
|
|
1846
|
+
return methods;
|
|
1847
|
+
}
|
|
1848
|
+
async listNotificationMethods() {
|
|
1849
|
+
if (this.notificationCache) {
|
|
1850
|
+
return this.notificationCache;
|
|
1851
|
+
}
|
|
1852
|
+
const outDir = await mkdtemp2(join2(tmpdir2(), "codex-web-local-schema-"));
|
|
1853
|
+
await this.runGenerateSchemaCommand(outDir);
|
|
1854
|
+
const serverNotificationPath = join2(outDir, "ServerNotification.json");
|
|
1855
|
+
const raw = await readFile2(serverNotificationPath, "utf8");
|
|
1856
|
+
const parsed = JSON.parse(raw);
|
|
1857
|
+
const methods = this.extractMethodsFromServerNotification(parsed);
|
|
1858
|
+
this.notificationCache = methods;
|
|
1859
|
+
return methods;
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
var SHARED_BRIDGE_KEY = "__codexRemoteSharedBridge__";
|
|
1863
|
+
var SHARED_BRIDGE_VERSION = "experimental-api-v2";
|
|
1864
|
+
function getSharedBridgeState() {
|
|
1865
|
+
const globalScope = globalThis;
|
|
1866
|
+
const existing = globalScope[SHARED_BRIDGE_KEY];
|
|
1867
|
+
if (existing) {
|
|
1868
|
+
if (existing.version === SHARED_BRIDGE_VERSION) {
|
|
1869
|
+
return existing;
|
|
1870
|
+
}
|
|
1871
|
+
existing.appServer.dispose();
|
|
1872
|
+
}
|
|
1873
|
+
const created = {
|
|
1874
|
+
version: SHARED_BRIDGE_VERSION,
|
|
1875
|
+
appServer: new AppServerProcess(),
|
|
1876
|
+
methodCatalog: new MethodCatalog()
|
|
1877
|
+
};
|
|
1878
|
+
globalScope[SHARED_BRIDGE_KEY] = created;
|
|
1879
|
+
return created;
|
|
1880
|
+
}
|
|
1881
|
+
async function loadAllThreadsForSearch(appServer) {
|
|
1882
|
+
const threads = [];
|
|
1883
|
+
let cursor = null;
|
|
1884
|
+
do {
|
|
1885
|
+
const response = asRecord2(await appServer.rpc("thread/list", {
|
|
1886
|
+
archived: false,
|
|
1887
|
+
limit: 100,
|
|
1888
|
+
sortKey: "updated_at",
|
|
1889
|
+
cursor
|
|
1890
|
+
}));
|
|
1891
|
+
const data = Array.isArray(response?.data) ? response.data : [];
|
|
1892
|
+
for (const row of data) {
|
|
1893
|
+
const record = asRecord2(row);
|
|
1894
|
+
const id = typeof record?.id === "string" ? record.id : "";
|
|
1895
|
+
if (!id) continue;
|
|
1896
|
+
const title = typeof record?.name === "string" && record.name.trim().length > 0 ? record.name.trim() : typeof record?.preview === "string" && record.preview.trim().length > 0 ? record.preview.trim() : "Untitled thread";
|
|
1897
|
+
const preview = typeof record?.preview === "string" ? record.preview : "";
|
|
1898
|
+
threads.push({ id, title, preview });
|
|
1899
|
+
}
|
|
1900
|
+
cursor = typeof response?.nextCursor === "string" && response.nextCursor.length > 0 ? response.nextCursor : null;
|
|
1901
|
+
} while (cursor);
|
|
1902
|
+
const docs = [];
|
|
1903
|
+
const concurrency = 4;
|
|
1904
|
+
for (let offset = 0; offset < threads.length; offset += concurrency) {
|
|
1905
|
+
const batch = threads.slice(offset, offset + concurrency);
|
|
1906
|
+
const loaded = await Promise.all(batch.map(async (thread) => {
|
|
1907
|
+
try {
|
|
1908
|
+
const readResponse = await appServer.rpc("thread/read", {
|
|
1909
|
+
threadId: thread.id,
|
|
1910
|
+
includeTurns: true
|
|
1911
|
+
});
|
|
1912
|
+
const messageText = extractThreadMessageText(readResponse);
|
|
1913
|
+
const searchableText = [thread.title, thread.preview, messageText].filter(Boolean).join("\n");
|
|
1914
|
+
return {
|
|
1915
|
+
id: thread.id,
|
|
1916
|
+
title: thread.title,
|
|
1917
|
+
preview: thread.preview,
|
|
1918
|
+
messageText,
|
|
1919
|
+
searchableText
|
|
1920
|
+
};
|
|
1921
|
+
} catch {
|
|
1922
|
+
const searchableText = [thread.title, thread.preview].filter(Boolean).join("\n");
|
|
1923
|
+
return {
|
|
1924
|
+
id: thread.id,
|
|
1925
|
+
title: thread.title,
|
|
1926
|
+
preview: thread.preview,
|
|
1927
|
+
messageText: "",
|
|
1928
|
+
searchableText
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
}));
|
|
1932
|
+
docs.push(...loaded);
|
|
1933
|
+
}
|
|
1934
|
+
return docs;
|
|
1935
|
+
}
|
|
1936
|
+
async function buildThreadSearchIndex(appServer) {
|
|
1937
|
+
const docs = await loadAllThreadsForSearch(appServer);
|
|
1938
|
+
const docsById = new Map(docs.map((doc) => [doc.id, doc]));
|
|
1939
|
+
return { docsById };
|
|
1940
|
+
}
|
|
1941
|
+
function createCodexBridgeMiddleware() {
|
|
1942
|
+
const { appServer, methodCatalog } = getSharedBridgeState();
|
|
1943
|
+
let threadSearchIndex = null;
|
|
1944
|
+
let threadSearchIndexPromise = null;
|
|
1945
|
+
async function getThreadSearchIndex() {
|
|
1946
|
+
if (threadSearchIndex) return threadSearchIndex;
|
|
1947
|
+
if (!threadSearchIndexPromise) {
|
|
1948
|
+
threadSearchIndexPromise = buildThreadSearchIndex(appServer).then((index) => {
|
|
1949
|
+
threadSearchIndex = index;
|
|
1950
|
+
return index;
|
|
1951
|
+
}).finally(() => {
|
|
1952
|
+
threadSearchIndexPromise = null;
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
return threadSearchIndexPromise;
|
|
1956
|
+
}
|
|
1957
|
+
void initializeSkillsSyncOnStartup(appServer);
|
|
1958
|
+
const middleware = async (req, res, next) => {
|
|
1959
|
+
try {
|
|
1960
|
+
if (!req.url) {
|
|
1961
|
+
next();
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const url = new URL(req.url, "http://localhost");
|
|
1965
|
+
if (await handleSkillsRoutes(req, res, url, { appServer, readJsonBody })) {
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (req.method === "POST" && url.pathname === "/codex-api/upload-file") {
|
|
1969
|
+
handleFileUpload(req, res);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (req.method === "POST" && url.pathname === "/codex-api/rpc") {
|
|
1973
|
+
const payload = await readJsonBody(req);
|
|
1974
|
+
const body = asRecord2(payload);
|
|
1975
|
+
if (!body || typeof body.method !== "string" || body.method.length === 0) {
|
|
1976
|
+
setJson2(res, 400, { error: "Invalid body: expected { method, params? }" });
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
const result = await appServer.rpc(body.method, body.params ?? null);
|
|
1980
|
+
setJson2(res, 200, { result });
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (req.method === "POST" && url.pathname === "/codex-api/transcribe") {
|
|
1984
|
+
const auth = await readCodexAuth();
|
|
1985
|
+
if (!auth) {
|
|
1986
|
+
setJson2(res, 401, { error: "No auth token available for transcription" });
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const rawBody = await readRawBody(req);
|
|
1990
|
+
const incomingCt = req.headers["content-type"] ?? "application/octet-stream";
|
|
1991
|
+
const upstream = await proxyTranscribe(rawBody, incomingCt, auth.accessToken, auth.accountId);
|
|
1992
|
+
res.statusCode = upstream.status;
|
|
1993
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1994
|
+
res.end(upstream.body);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (req.method === "POST" && url.pathname === "/codex-api/server-requests/respond") {
|
|
1998
|
+
const payload = await readJsonBody(req);
|
|
1999
|
+
await appServer.respondToServerRequest(payload);
|
|
2000
|
+
setJson2(res, 200, { ok: true });
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (req.method === "GET" && url.pathname === "/codex-api/server-requests/pending") {
|
|
2004
|
+
setJson2(res, 200, { data: appServer.listPendingServerRequests() });
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (req.method === "GET" && url.pathname === "/codex-api/meta/methods") {
|
|
2008
|
+
const methods = await methodCatalog.listMethods();
|
|
2009
|
+
setJson2(res, 200, { data: methods });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (req.method === "GET" && url.pathname === "/codex-api/meta/notifications") {
|
|
2013
|
+
const methods = await methodCatalog.listNotificationMethods();
|
|
2014
|
+
setJson2(res, 200, { data: methods });
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (req.method === "GET" && url.pathname === "/codex-api/workspace-roots-state") {
|
|
2018
|
+
const state = await readWorkspaceRootsState();
|
|
2019
|
+
setJson2(res, 200, { data: state });
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (req.method === "GET" && url.pathname === "/codex-api/home-directory") {
|
|
2023
|
+
setJson2(res, 200, { data: { path: homedir2() } });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (req.method === "POST" && url.pathname === "/codex-api/worktree/create") {
|
|
2027
|
+
const payload = asRecord2(await readJsonBody(req));
|
|
2028
|
+
const rawSourceCwd = typeof payload?.sourceCwd === "string" ? payload.sourceCwd.trim() : "";
|
|
2029
|
+
if (!rawSourceCwd) {
|
|
2030
|
+
setJson2(res, 400, { error: "Missing sourceCwd" });
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const sourceCwd = isAbsolute(rawSourceCwd) ? rawSourceCwd : resolve(rawSourceCwd);
|
|
2034
|
+
try {
|
|
2035
|
+
const sourceInfo = await stat2(sourceCwd);
|
|
2036
|
+
if (!sourceInfo.isDirectory()) {
|
|
2037
|
+
setJson2(res, 400, { error: "sourceCwd is not a directory" });
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
} catch {
|
|
2041
|
+
setJson2(res, 404, { error: "sourceCwd does not exist" });
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
let gitRoot = "";
|
|
2046
|
+
try {
|
|
2047
|
+
gitRoot = await runCommandCapture("git", ["rev-parse", "--show-toplevel"], { cwd: sourceCwd });
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
if (!isNotGitRepositoryError(error)) throw error;
|
|
2050
|
+
await runCommand2("git", ["init"], { cwd: sourceCwd });
|
|
2051
|
+
gitRoot = await runCommandCapture("git", ["rev-parse", "--show-toplevel"], { cwd: sourceCwd });
|
|
2052
|
+
}
|
|
2053
|
+
const repoName = basename(gitRoot) || "repo";
|
|
2054
|
+
const worktreesRoot = join2(getCodexHomeDir2(), "worktrees");
|
|
2055
|
+
await mkdir2(worktreesRoot, { recursive: true });
|
|
2056
|
+
let worktreeId = "";
|
|
2057
|
+
let worktreeParent = "";
|
|
2058
|
+
let worktreeCwd = "";
|
|
2059
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
2060
|
+
const candidate = randomBytes(2).toString("hex");
|
|
2061
|
+
const parent = join2(worktreesRoot, candidate);
|
|
2062
|
+
try {
|
|
2063
|
+
await stat2(parent);
|
|
2064
|
+
continue;
|
|
2065
|
+
} catch {
|
|
2066
|
+
worktreeId = candidate;
|
|
2067
|
+
worktreeParent = parent;
|
|
2068
|
+
worktreeCwd = join2(parent, repoName);
|
|
2069
|
+
break;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
if (!worktreeId || !worktreeParent || !worktreeCwd) {
|
|
2073
|
+
throw new Error("Failed to allocate a unique worktree id");
|
|
2074
|
+
}
|
|
2075
|
+
const branch = `codex/${worktreeId}`;
|
|
2076
|
+
await mkdir2(worktreeParent, { recursive: true });
|
|
2077
|
+
try {
|
|
2078
|
+
await runCommand2("git", ["worktree", "add", "-b", branch, worktreeCwd, "HEAD"], { cwd: gitRoot });
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
if (!isMissingHeadError(error)) throw error;
|
|
2081
|
+
await ensureRepoHasInitialCommit(gitRoot);
|
|
2082
|
+
await runCommand2("git", ["worktree", "add", "-b", branch, worktreeCwd, "HEAD"], { cwd: gitRoot });
|
|
2083
|
+
}
|
|
2084
|
+
setJson2(res, 200, {
|
|
2085
|
+
data: {
|
|
2086
|
+
cwd: worktreeCwd,
|
|
2087
|
+
branch,
|
|
2088
|
+
gitRoot
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
setJson2(res, 500, { error: getErrorMessage2(error, "Failed to create worktree") });
|
|
2093
|
+
}
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (req.method === "PUT" && url.pathname === "/codex-api/workspace-roots-state") {
|
|
2097
|
+
const payload = await readJsonBody(req);
|
|
2098
|
+
const record = asRecord2(payload);
|
|
2099
|
+
if (!record) {
|
|
2100
|
+
setJson2(res, 400, { error: "Invalid body: expected object" });
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
const nextState = {
|
|
2104
|
+
order: normalizeStringArray(record.order),
|
|
2105
|
+
labels: normalizeStringRecord(record.labels),
|
|
2106
|
+
active: normalizeStringArray(record.active)
|
|
2107
|
+
};
|
|
2108
|
+
await writeWorkspaceRootsState(nextState);
|
|
2109
|
+
setJson2(res, 200, { ok: true });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (req.method === "POST" && url.pathname === "/codex-api/project-root") {
|
|
2113
|
+
const payload = asRecord2(await readJsonBody(req));
|
|
2114
|
+
const rawPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
|
2115
|
+
const createIfMissing = payload?.createIfMissing === true;
|
|
2116
|
+
const label = typeof payload?.label === "string" ? payload.label : "";
|
|
2117
|
+
if (!rawPath) {
|
|
2118
|
+
setJson2(res, 400, { error: "Missing path" });
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const normalizedPath = isAbsolute(rawPath) ? rawPath : resolve(rawPath);
|
|
2122
|
+
let pathExists = true;
|
|
2123
|
+
try {
|
|
2124
|
+
const info = await stat2(normalizedPath);
|
|
2125
|
+
if (!info.isDirectory()) {
|
|
2126
|
+
setJson2(res, 400, { error: "Path exists but is not a directory" });
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
} catch {
|
|
2130
|
+
pathExists = false;
|
|
2131
|
+
}
|
|
2132
|
+
if (!pathExists && createIfMissing) {
|
|
2133
|
+
await mkdir2(normalizedPath, { recursive: true });
|
|
2134
|
+
} else if (!pathExists) {
|
|
2135
|
+
setJson2(res, 404, { error: "Directory does not exist" });
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const existingState = await readWorkspaceRootsState();
|
|
2139
|
+
const nextOrder = [normalizedPath, ...existingState.order.filter((item) => item !== normalizedPath)];
|
|
2140
|
+
const nextActive = [normalizedPath, ...existingState.active.filter((item) => item !== normalizedPath)];
|
|
2141
|
+
const nextLabels = { ...existingState.labels };
|
|
2142
|
+
if (label.trim().length > 0) {
|
|
2143
|
+
nextLabels[normalizedPath] = label.trim();
|
|
2144
|
+
}
|
|
2145
|
+
await writeWorkspaceRootsState({
|
|
2146
|
+
order: nextOrder,
|
|
2147
|
+
labels: nextLabels,
|
|
2148
|
+
active: nextActive
|
|
2149
|
+
});
|
|
2150
|
+
setJson2(res, 200, { data: { path: normalizedPath } });
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
if (req.method === "GET" && url.pathname === "/codex-api/project-root-suggestion") {
|
|
2154
|
+
const basePath = url.searchParams.get("basePath")?.trim() ?? "";
|
|
2155
|
+
if (!basePath) {
|
|
2156
|
+
setJson2(res, 400, { error: "Missing basePath" });
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
const normalizedBasePath = isAbsolute(basePath) ? basePath : resolve(basePath);
|
|
2160
|
+
try {
|
|
2161
|
+
const baseInfo = await stat2(normalizedBasePath);
|
|
2162
|
+
if (!baseInfo.isDirectory()) {
|
|
2163
|
+
setJson2(res, 400, { error: "basePath is not a directory" });
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
} catch {
|
|
2167
|
+
setJson2(res, 404, { error: "basePath does not exist" });
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
let index = 1;
|
|
2171
|
+
while (index < 1e5) {
|
|
2172
|
+
const candidateName = `New Project (${String(index)})`;
|
|
2173
|
+
const candidatePath = join2(normalizedBasePath, candidateName);
|
|
2174
|
+
try {
|
|
2175
|
+
await stat2(candidatePath);
|
|
2176
|
+
index += 1;
|
|
2177
|
+
continue;
|
|
2178
|
+
} catch {
|
|
2179
|
+
setJson2(res, 200, { data: { name: candidateName, path: candidatePath } });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
setJson2(res, 500, { error: "Failed to compute project name suggestion" });
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (req.method === "POST" && url.pathname === "/codex-api/composer-file-search") {
|
|
2187
|
+
const payload = asRecord2(await readJsonBody(req));
|
|
2188
|
+
const rawCwd = typeof payload?.cwd === "string" ? payload.cwd.trim() : "";
|
|
2189
|
+
const query = typeof payload?.query === "string" ? payload.query.trim() : "";
|
|
2190
|
+
const limitRaw = typeof payload?.limit === "number" ? payload.limit : 20;
|
|
2191
|
+
const limit = Math.max(1, Math.min(100, Math.floor(limitRaw)));
|
|
2192
|
+
if (!rawCwd) {
|
|
2193
|
+
setJson2(res, 400, { error: "Missing cwd" });
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const cwd = isAbsolute(rawCwd) ? rawCwd : resolve(rawCwd);
|
|
2197
|
+
try {
|
|
2198
|
+
const info = await stat2(cwd);
|
|
2199
|
+
if (!info.isDirectory()) {
|
|
2200
|
+
setJson2(res, 400, { error: "cwd is not a directory" });
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
} catch {
|
|
2204
|
+
setJson2(res, 404, { error: "cwd does not exist" });
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
const files = await listFilesWithRipgrep(cwd);
|
|
2209
|
+
const scored = files.map((path) => ({ path, score: scoreFileCandidate(path, query) })).filter((row) => query.length === 0 || row.score < 10).sort((a, b) => a.score - b.score || a.path.localeCompare(b.path)).slice(0, limit).map((row) => ({ path: row.path }));
|
|
2210
|
+
setJson2(res, 200, { data: scored });
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
setJson2(res, 500, { error: getErrorMessage2(error, "Failed to search files") });
|
|
2213
|
+
}
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (req.method === "GET" && url.pathname === "/codex-api/thread-titles") {
|
|
2217
|
+
const cache = await readThreadTitleCache();
|
|
2218
|
+
setJson2(res, 200, { data: cache });
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
if (req.method === "POST" && url.pathname === "/codex-api/thread-search") {
|
|
2222
|
+
const payload = asRecord2(await readJsonBody(req));
|
|
2223
|
+
const query = typeof payload?.query === "string" ? payload.query.trim() : "";
|
|
2224
|
+
const limitRaw = typeof payload?.limit === "number" ? payload.limit : 200;
|
|
2225
|
+
const limit = Math.max(1, Math.min(1e3, Math.floor(limitRaw)));
|
|
2226
|
+
if (!query) {
|
|
2227
|
+
setJson2(res, 200, { data: { threadIds: [], indexedThreadCount: 0 } });
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const index = await getThreadSearchIndex();
|
|
2231
|
+
const matchedIds = Array.from(index.docsById.entries()).filter(([, doc]) => isExactPhraseMatch(query, doc)).slice(0, limit).map(([id]) => id);
|
|
2232
|
+
setJson2(res, 200, { data: { threadIds: matchedIds, indexedThreadCount: index.docsById.size } });
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
if (req.method === "PUT" && url.pathname === "/codex-api/thread-titles") {
|
|
2236
|
+
const payload = asRecord2(await readJsonBody(req));
|
|
2237
|
+
const id = typeof payload?.id === "string" ? payload.id : "";
|
|
2238
|
+
const title = typeof payload?.title === "string" ? payload.title : "";
|
|
2239
|
+
if (!id) {
|
|
2240
|
+
setJson2(res, 400, { error: "Missing id" });
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const cache = await readThreadTitleCache();
|
|
2244
|
+
const next2 = title ? updateThreadTitleCache(cache, id, title) : removeFromThreadTitleCache(cache, id);
|
|
2245
|
+
await writeThreadTitleCache(next2);
|
|
2246
|
+
setJson2(res, 200, { ok: true });
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (req.method === "GET" && url.pathname === "/codex-api/events") {
|
|
2250
|
+
res.statusCode = 200;
|
|
2251
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
2252
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
2253
|
+
res.setHeader("Connection", "keep-alive");
|
|
2254
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
2255
|
+
const unsubscribe = middleware.subscribeNotifications((notification) => {
|
|
2256
|
+
if (res.writableEnded || res.destroyed) return;
|
|
2257
|
+
res.write(`data: ${JSON.stringify(notification)}
|
|
2258
|
+
|
|
2259
|
+
`);
|
|
2260
|
+
});
|
|
2261
|
+
res.write(`event: ready
|
|
2262
|
+
data: ${JSON.stringify({ ok: true })}
|
|
2263
|
+
|
|
2264
|
+
`);
|
|
2265
|
+
const keepAlive = setInterval(() => {
|
|
2266
|
+
res.write(": ping\n\n");
|
|
2267
|
+
}, 15e3);
|
|
2268
|
+
const close = () => {
|
|
2269
|
+
clearInterval(keepAlive);
|
|
2270
|
+
unsubscribe();
|
|
2271
|
+
if (!res.writableEnded) {
|
|
2272
|
+
res.end();
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
req.on("close", close);
|
|
2276
|
+
req.on("aborted", close);
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
next();
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
const message = getErrorMessage2(error, "Unknown bridge error");
|
|
2282
|
+
setJson2(res, 502, { error: message });
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
middleware.dispose = () => {
|
|
2286
|
+
threadSearchIndex = null;
|
|
2287
|
+
appServer.dispose();
|
|
2288
|
+
};
|
|
2289
|
+
middleware.subscribeNotifications = (listener) => {
|
|
2290
|
+
return appServer.onNotification((notification) => {
|
|
2291
|
+
listener({
|
|
2292
|
+
...notification,
|
|
2293
|
+
atIso: (/* @__PURE__ */ new Date()).toISOString()
|
|
2294
|
+
});
|
|
2295
|
+
});
|
|
2296
|
+
};
|
|
2297
|
+
return middleware;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// src/server/authMiddleware.ts
|
|
2301
|
+
import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
2302
|
+
var TOKEN_COOKIE = "codex_web_local_token";
|
|
2303
|
+
function constantTimeCompare(a, b) {
|
|
2304
|
+
const bufA = Buffer.from(a);
|
|
2305
|
+
const bufB = Buffer.from(b);
|
|
2306
|
+
if (bufA.length !== bufB.length) return false;
|
|
2307
|
+
return timingSafeEqual(bufA, bufB);
|
|
2308
|
+
}
|
|
2309
|
+
function parseCookies(header) {
|
|
2310
|
+
const cookies = {};
|
|
2311
|
+
if (!header) return cookies;
|
|
2312
|
+
for (const pair of header.split(";")) {
|
|
2313
|
+
const idx = pair.indexOf("=");
|
|
2314
|
+
if (idx === -1) continue;
|
|
2315
|
+
const key = pair.slice(0, idx).trim();
|
|
2316
|
+
const value = pair.slice(idx + 1).trim();
|
|
2317
|
+
cookies[key] = value;
|
|
2318
|
+
}
|
|
2319
|
+
return cookies;
|
|
2320
|
+
}
|
|
2321
|
+
function isLocalhostRemote(remote) {
|
|
2322
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
2323
|
+
}
|
|
2324
|
+
function isLocalhostHost(host) {
|
|
2325
|
+
const normalized = host.toLowerCase();
|
|
2326
|
+
return normalized.startsWith("localhost:") || normalized === "localhost" || normalized.startsWith("127.0.0.1:");
|
|
2327
|
+
}
|
|
2328
|
+
function isAuthorizedByRequestLike(remoteAddress, hostHeader, cookieHeader, validTokens) {
|
|
2329
|
+
const remote = remoteAddress ?? "";
|
|
2330
|
+
if (isLocalhostRemote(remote) || isLocalhostHost(hostHeader ?? "")) {
|
|
2331
|
+
return true;
|
|
2332
|
+
}
|
|
2333
|
+
const cookies = parseCookies(cookieHeader);
|
|
2334
|
+
const token = cookies[TOKEN_COOKIE];
|
|
2335
|
+
return Boolean(token && validTokens.has(token));
|
|
2336
|
+
}
|
|
2337
|
+
var LOGIN_PAGE_HTML = `<!DOCTYPE html>
|
|
2338
|
+
<html lang="en">
|
|
2339
|
+
<head>
|
|
2340
|
+
<meta charset="utf-8">
|
|
2341
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2342
|
+
<title>Codex Web</title>
|
|
2343
|
+
<style>
|
|
2344
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
2345
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
|
|
2346
|
+
.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:2rem;width:100%;max-width:380px}
|
|
2347
|
+
h1{font-size:1.25rem;font-weight:600;margin-bottom:1.5rem;text-align:center;color:#fafafa}
|
|
2348
|
+
label{display:block;font-size:.875rem;color:#a3a3a3;margin-bottom:.5rem}
|
|
2349
|
+
input{width:100%;padding:.625rem .75rem;background:#0a0a0a;border:1px solid #404040;border-radius:8px;color:#fafafa;font-size:1rem;outline:none;transition:border-color .15s}
|
|
2350
|
+
input:focus{border-color:#3b82f6}
|
|
2351
|
+
button{width:100%;padding:.625rem;margin-top:1rem;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:.9375rem;font-weight:500;cursor:pointer;transition:background .15s}
|
|
2352
|
+
button:hover{background:#2563eb}
|
|
2353
|
+
.error{color:#ef4444;font-size:.8125rem;margin-top:.75rem;text-align:center;display:none}
|
|
2354
|
+
</style>
|
|
2355
|
+
</head>
|
|
2356
|
+
<body>
|
|
2357
|
+
<div class="card">
|
|
2358
|
+
<h1>Codex Web</h1>
|
|
2359
|
+
<form id="f">
|
|
2360
|
+
<label for="pw">Password</label>
|
|
2361
|
+
<input id="pw" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
2362
|
+
<button type="submit">Sign in</button>
|
|
2363
|
+
<p class="error" id="err">Incorrect password</p>
|
|
2364
|
+
</form>
|
|
2365
|
+
</div>
|
|
2366
|
+
<script>
|
|
2367
|
+
const form=document.getElementById('f');
|
|
2368
|
+
const errEl=document.getElementById('err');
|
|
2369
|
+
form.addEventListener('submit',async e=>{
|
|
2370
|
+
e.preventDefault();
|
|
2371
|
+
errEl.style.display='none';
|
|
2372
|
+
const res=await fetch('/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('pw').value})});
|
|
2373
|
+
if(res.ok){window.location.reload()}else{errEl.style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus()}
|
|
2374
|
+
});
|
|
2375
|
+
</script>
|
|
2376
|
+
</body>
|
|
2377
|
+
</html>`;
|
|
2378
|
+
function createAuthSession(password) {
|
|
2379
|
+
const validTokens = /* @__PURE__ */ new Set();
|
|
2380
|
+
const middleware = (req, res, next) => {
|
|
2381
|
+
if (isAuthorizedByRequestLike(req.socket.remoteAddress, req.headers.host, req.headers.cookie, validTokens)) {
|
|
2382
|
+
next();
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
if (req.method === "POST" && req.path === "/auth/login") {
|
|
2386
|
+
let body = "";
|
|
2387
|
+
req.setEncoding("utf8");
|
|
2388
|
+
req.on("data", (chunk) => {
|
|
2389
|
+
body += chunk;
|
|
2390
|
+
});
|
|
2391
|
+
req.on("end", () => {
|
|
2392
|
+
try {
|
|
2393
|
+
const parsed = JSON.parse(body);
|
|
2394
|
+
const provided = typeof parsed.password === "string" ? parsed.password : "";
|
|
2395
|
+
if (!constantTimeCompare(provided, password)) {
|
|
2396
|
+
res.status(401).json({ error: "Invalid password" });
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
const token = randomBytes2(32).toString("hex");
|
|
2400
|
+
validTokens.add(token);
|
|
2401
|
+
res.setHeader("Set-Cookie", `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`);
|
|
2402
|
+
res.json({ ok: true });
|
|
2403
|
+
} catch {
|
|
2404
|
+
res.status(400).json({ error: "Invalid request body" });
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2410
|
+
res.status(200).send(LOGIN_PAGE_HTML);
|
|
2411
|
+
};
|
|
2412
|
+
return {
|
|
2413
|
+
middleware,
|
|
2414
|
+
isRequestAuthorized: (req) => isAuthorizedByRequestLike(req.socket.remoteAddress, req.headers.host, req.headers.cookie, validTokens)
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// src/server/localBrowseUi.ts
|
|
2419
|
+
import { dirname, extname, join as join3 } from "path";
|
|
2420
|
+
import { open, readFile as readFile3, readdir as readdir3, stat as stat3 } from "fs/promises";
|
|
2421
|
+
var TEXT_EDITABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2422
|
+
".txt",
|
|
2423
|
+
".md",
|
|
2424
|
+
".json",
|
|
2425
|
+
".js",
|
|
2426
|
+
".ts",
|
|
2427
|
+
".tsx",
|
|
2428
|
+
".jsx",
|
|
2429
|
+
".css",
|
|
2430
|
+
".scss",
|
|
2431
|
+
".html",
|
|
2432
|
+
".htm",
|
|
2433
|
+
".xml",
|
|
2434
|
+
".yml",
|
|
2435
|
+
".yaml",
|
|
2436
|
+
".log",
|
|
2437
|
+
".csv",
|
|
2438
|
+
".env",
|
|
2439
|
+
".py",
|
|
2440
|
+
".sh",
|
|
2441
|
+
".toml",
|
|
2442
|
+
".ini",
|
|
2443
|
+
".conf",
|
|
2444
|
+
".sql",
|
|
2445
|
+
".bat",
|
|
2446
|
+
".cmd",
|
|
2447
|
+
".ps1"
|
|
2448
|
+
]);
|
|
2449
|
+
function languageForPath(pathValue) {
|
|
2450
|
+
const extension = extname(pathValue).toLowerCase();
|
|
2451
|
+
switch (extension) {
|
|
2452
|
+
case ".js":
|
|
2453
|
+
return "javascript";
|
|
2454
|
+
case ".ts":
|
|
2455
|
+
return "typescript";
|
|
2456
|
+
case ".jsx":
|
|
2457
|
+
return "javascript";
|
|
2458
|
+
case ".tsx":
|
|
2459
|
+
return "typescript";
|
|
2460
|
+
case ".py":
|
|
2461
|
+
return "python";
|
|
2462
|
+
case ".sh":
|
|
2463
|
+
return "sh";
|
|
2464
|
+
case ".css":
|
|
2465
|
+
case ".scss":
|
|
2466
|
+
return "css";
|
|
2467
|
+
case ".html":
|
|
2468
|
+
case ".htm":
|
|
2469
|
+
return "html";
|
|
2470
|
+
case ".json":
|
|
2471
|
+
return "json";
|
|
2472
|
+
case ".md":
|
|
2473
|
+
return "markdown";
|
|
2474
|
+
case ".yaml":
|
|
2475
|
+
case ".yml":
|
|
2476
|
+
return "yaml";
|
|
2477
|
+
case ".xml":
|
|
2478
|
+
return "xml";
|
|
2479
|
+
case ".sql":
|
|
2480
|
+
return "sql";
|
|
2481
|
+
case ".toml":
|
|
2482
|
+
return "ini";
|
|
2483
|
+
case ".ini":
|
|
2484
|
+
case ".conf":
|
|
2485
|
+
return "ini";
|
|
2486
|
+
default:
|
|
2487
|
+
return "plaintext";
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
function normalizeLocalPath(rawPath) {
|
|
2491
|
+
const trimmed = rawPath.trim();
|
|
2492
|
+
if (!trimmed) return "";
|
|
2493
|
+
if (trimmed.startsWith("file://")) {
|
|
2494
|
+
try {
|
|
2495
|
+
return decodeURIComponent(trimmed.replace(/^file:\/\//u, ""));
|
|
2496
|
+
} catch {
|
|
2497
|
+
return trimmed.replace(/^file:\/\//u, "");
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
return trimmed;
|
|
2501
|
+
}
|
|
2502
|
+
function decodeBrowsePath(rawPath) {
|
|
2503
|
+
if (!rawPath) return "";
|
|
2504
|
+
try {
|
|
2505
|
+
return decodeURIComponent(rawPath);
|
|
2506
|
+
} catch {
|
|
2507
|
+
return rawPath;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
function isTextEditablePath(pathValue) {
|
|
2511
|
+
return TEXT_EDITABLE_EXTENSIONS.has(extname(pathValue).toLowerCase());
|
|
2512
|
+
}
|
|
2513
|
+
function looksLikeTextBuffer(buffer) {
|
|
2514
|
+
if (buffer.length === 0) return true;
|
|
2515
|
+
for (const byte of buffer) {
|
|
2516
|
+
if (byte === 0) return false;
|
|
2517
|
+
}
|
|
2518
|
+
const decoded = buffer.toString("utf8");
|
|
2519
|
+
const replacementCount = (decoded.match(/\uFFFD/gu) ?? []).length;
|
|
2520
|
+
return replacementCount / decoded.length < 0.05;
|
|
2521
|
+
}
|
|
2522
|
+
async function probeFileIsText(localPath) {
|
|
2523
|
+
const handle = await open(localPath, "r");
|
|
2524
|
+
try {
|
|
2525
|
+
const sample = Buffer.allocUnsafe(4096);
|
|
2526
|
+
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
|
2527
|
+
return looksLikeTextBuffer(sample.subarray(0, bytesRead));
|
|
2528
|
+
} finally {
|
|
2529
|
+
await handle.close();
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
async function isTextEditableFile(localPath) {
|
|
2533
|
+
if (isTextEditablePath(localPath)) return true;
|
|
2534
|
+
try {
|
|
2535
|
+
const fileStat = await stat3(localPath);
|
|
2536
|
+
if (!fileStat.isFile()) return false;
|
|
2537
|
+
return await probeFileIsText(localPath);
|
|
2538
|
+
} catch {
|
|
2539
|
+
return false;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
function escapeHtml(value) {
|
|
2543
|
+
return value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, """).replace(/'/gu, "'");
|
|
2544
|
+
}
|
|
2545
|
+
function toBrowseHref(pathValue) {
|
|
2546
|
+
return `/codex-local-browse${encodeURI(pathValue)}`;
|
|
2547
|
+
}
|
|
2548
|
+
function toEditHref(pathValue) {
|
|
2549
|
+
return `/codex-local-edit${encodeURI(pathValue)}`;
|
|
2550
|
+
}
|
|
2551
|
+
function escapeForInlineScriptString(value) {
|
|
2552
|
+
return JSON.stringify(value).replace(/<\//gu, "<\\/").replace(/<!--/gu, "<\\!--").replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
|
|
2553
|
+
}
|
|
2554
|
+
async function getDirectoryItems(localPath) {
|
|
2555
|
+
const entries = await readdir3(localPath, { withFileTypes: true });
|
|
2556
|
+
const withMeta = await Promise.all(entries.map(async (entry) => {
|
|
2557
|
+
const entryPath = join3(localPath, entry.name);
|
|
2558
|
+
const entryStat = await stat3(entryPath);
|
|
2559
|
+
const editable = !entry.isDirectory() && await isTextEditableFile(entryPath);
|
|
2560
|
+
return {
|
|
2561
|
+
name: entry.name,
|
|
2562
|
+
path: entryPath,
|
|
2563
|
+
isDirectory: entry.isDirectory(),
|
|
2564
|
+
editable,
|
|
2565
|
+
mtimeMs: entryStat.mtimeMs
|
|
2566
|
+
};
|
|
2567
|
+
}));
|
|
2568
|
+
return withMeta.sort((a, b) => {
|
|
2569
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
2570
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
2571
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
2572
|
+
return a.name.localeCompare(b.name);
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
async function createDirectoryListingHtml(localPath) {
|
|
2576
|
+
const items = await getDirectoryItems(localPath);
|
|
2577
|
+
const parentPath = dirname(localPath);
|
|
2578
|
+
const rows = items.map((item) => {
|
|
2579
|
+
const suffix = item.isDirectory ? "/" : "";
|
|
2580
|
+
const editAction = item.editable ? ` <a class="icon-btn" aria-label="Edit ${escapeHtml(item.name)}" href="${escapeHtml(toEditHref(item.path))}" title="Edit">\u270F\uFE0F</a>` : "";
|
|
2581
|
+
return `<li class="file-row"><a class="file-link" href="${escapeHtml(toBrowseHref(item.path))}">${escapeHtml(item.name)}${suffix}</a>${editAction}</li>`;
|
|
2582
|
+
}).join("\n");
|
|
2583
|
+
const parentLink = localPath !== parentPath ? `<p><a href="${escapeHtml(toBrowseHref(parentPath))}">..</a></p>` : "";
|
|
2584
|
+
return `<!doctype html>
|
|
2585
|
+
<html lang="en">
|
|
2586
|
+
<head>
|
|
2587
|
+
<meta charset="utf-8" />
|
|
2588
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2589
|
+
<title>Index of ${escapeHtml(localPath)}</title>
|
|
2590
|
+
<style>
|
|
2591
|
+
body { font-family: ui-monospace, Menlo, Monaco, monospace; margin: 16px; background: #0b1020; color: #dbe6ff; }
|
|
2592
|
+
a { color: #8cc2ff; text-decoration: none; }
|
|
2593
|
+
a:hover { text-decoration: underline; }
|
|
2594
|
+
ul { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 8px; }
|
|
2595
|
+
.file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 10px; }
|
|
2596
|
+
.file-link { display: block; padding: 10px 12px; border: 1px solid #28405f; border-radius: 10px; background: #0f1b33; overflow-wrap: anywhere; }
|
|
2597
|
+
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border: 1px solid #36557a; border-radius: 10px; background: #162643; text-decoration: none; }
|
|
2598
|
+
.icon-btn:hover { filter: brightness(1.08); text-decoration: none; }
|
|
2599
|
+
h1 { font-size: 18px; margin: 0; word-break: break-all; }
|
|
2600
|
+
@media (max-width: 640px) {
|
|
2601
|
+
body { margin: 12px; }
|
|
2602
|
+
.file-row { gap: 8px; }
|
|
2603
|
+
.file-link { font-size: 15px; padding: 12px; }
|
|
2604
|
+
.icon-btn { width: 44px; height: 44px; }
|
|
2605
|
+
}
|
|
2606
|
+
</style>
|
|
2607
|
+
</head>
|
|
2608
|
+
<body>
|
|
2609
|
+
<h1>Index of ${escapeHtml(localPath)}</h1>
|
|
2610
|
+
${parentLink}
|
|
2611
|
+
<ul>${rows}</ul>
|
|
2612
|
+
</body>
|
|
2613
|
+
</html>`;
|
|
2614
|
+
}
|
|
2615
|
+
async function createTextEditorHtml(localPath) {
|
|
2616
|
+
const content = await readFile3(localPath, "utf8");
|
|
2617
|
+
const parentPath = dirname(localPath);
|
|
2618
|
+
const language = languageForPath(localPath);
|
|
2619
|
+
const safeContentLiteral = escapeForInlineScriptString(content);
|
|
2620
|
+
return `<!doctype html>
|
|
2621
|
+
<html lang="en">
|
|
2622
|
+
<head>
|
|
2623
|
+
<meta charset="utf-8" />
|
|
2624
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2625
|
+
<title>Edit ${escapeHtml(localPath)}</title>
|
|
2626
|
+
<style>
|
|
2627
|
+
html, body { width: 100%; height: 100%; margin: 0; }
|
|
2628
|
+
body { font-family: ui-monospace, Menlo, Monaco, monospace; background: #0b1020; color: #dbe6ff; display: flex; flex-direction: column; overflow: hidden; }
|
|
2629
|
+
.toolbar { position: sticky; top: 0; z-index: 10; display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; background: #0b1020; border-bottom: 1px solid #243a5a; }
|
|
2630
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
2631
|
+
button, a { background: #1b2a4a; color: #dbe6ff; border: 1px solid #345; padding: 6px 10px; border-radius: 6px; text-decoration: none; cursor: pointer; }
|
|
2632
|
+
button:hover, a:hover { filter: brightness(1.08); }
|
|
2633
|
+
#editor { flex: 1 1 auto; min-height: 0; width: 100%; border: none; overflow: hidden; }
|
|
2634
|
+
#status { margin-left: 8px; color: #8cc2ff; }
|
|
2635
|
+
.ace_editor { background: #07101f !important; color: #dbe6ff !important; width: 100% !important; height: 100% !important; }
|
|
2636
|
+
.ace_gutter { background: #07101f !important; color: #6f8eb5 !important; }
|
|
2637
|
+
.ace_marker-layer .ace_active-line { background: #10213c !important; }
|
|
2638
|
+
.ace_marker-layer .ace_selection { background: rgba(140, 194, 255, 0.3) !important; }
|
|
2639
|
+
.meta { opacity: 0.9; font-size: 12px; overflow-wrap: anywhere; }
|
|
2640
|
+
</style>
|
|
2641
|
+
</head>
|
|
2642
|
+
<body>
|
|
2643
|
+
<div class="toolbar">
|
|
2644
|
+
<div class="row">
|
|
2645
|
+
<a href="${escapeHtml(toBrowseHref(parentPath))}">Back</a>
|
|
2646
|
+
<button id="saveBtn" type="button">Save</button>
|
|
2647
|
+
<span id="status"></span>
|
|
2648
|
+
</div>
|
|
2649
|
+
<div class="meta">${escapeHtml(localPath)} \xB7 ${escapeHtml(language)}</div>
|
|
2650
|
+
</div>
|
|
2651
|
+
<div id="editor"></div>
|
|
2652
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.js"></script>
|
|
2653
|
+
<script>
|
|
2654
|
+
const saveBtn = document.getElementById('saveBtn');
|
|
2655
|
+
const status = document.getElementById('status');
|
|
2656
|
+
const editor = ace.edit('editor');
|
|
2657
|
+
editor.setTheme('ace/theme/tomorrow_night');
|
|
2658
|
+
editor.session.setMode('ace/mode/${escapeHtml(language)}');
|
|
2659
|
+
editor.setValue(${safeContentLiteral}, -1);
|
|
2660
|
+
editor.setOptions({
|
|
2661
|
+
fontSize: '13px',
|
|
2662
|
+
wrap: true,
|
|
2663
|
+
showPrintMargin: false,
|
|
2664
|
+
useSoftTabs: true,
|
|
2665
|
+
tabSize: 2,
|
|
2666
|
+
behavioursEnabled: true,
|
|
2667
|
+
});
|
|
2668
|
+
editor.resize();
|
|
2669
|
+
|
|
2670
|
+
saveBtn.addEventListener('click', async () => {
|
|
2671
|
+
status.textContent = 'Saving...';
|
|
2672
|
+
const response = await fetch(location.pathname, {
|
|
2673
|
+
method: 'PUT',
|
|
2674
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
2675
|
+
body: editor.getValue(),
|
|
2676
|
+
});
|
|
2677
|
+
status.textContent = response.ok ? 'Saved' : 'Save failed';
|
|
2678
|
+
});
|
|
2679
|
+
</script>
|
|
2680
|
+
</body>
|
|
2681
|
+
</html>`;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// src/server/httpServer.ts
|
|
2685
|
+
import { WebSocketServer } from "ws";
|
|
2686
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
2687
|
+
var distDir = join4(__dirname, "..", "dist");
|
|
2688
|
+
var spaEntryFile = join4(distDir, "index.html");
|
|
2689
|
+
var IMAGE_CONTENT_TYPES = {
|
|
2690
|
+
".avif": "image/avif",
|
|
2691
|
+
".bmp": "image/bmp",
|
|
2692
|
+
".gif": "image/gif",
|
|
2693
|
+
".jpeg": "image/jpeg",
|
|
2694
|
+
".jpg": "image/jpeg",
|
|
2695
|
+
".png": "image/png",
|
|
2696
|
+
".svg": "image/svg+xml",
|
|
2697
|
+
".webp": "image/webp"
|
|
2698
|
+
};
|
|
2699
|
+
function normalizeLocalImagePath(rawPath) {
|
|
2700
|
+
const trimmed = rawPath.trim();
|
|
2701
|
+
if (!trimmed) return "";
|
|
2702
|
+
if (trimmed.startsWith("file://")) {
|
|
2703
|
+
try {
|
|
2704
|
+
return decodeURIComponent(trimmed.replace(/^file:\/\//u, ""));
|
|
2705
|
+
} catch {
|
|
2706
|
+
return trimmed.replace(/^file:\/\//u, "");
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return trimmed;
|
|
2710
|
+
}
|
|
2711
|
+
function readWildcardPathParam(value) {
|
|
2712
|
+
if (typeof value === "string") return value;
|
|
2713
|
+
if (Array.isArray(value)) return value.join("/");
|
|
2714
|
+
return "";
|
|
2715
|
+
}
|
|
2716
|
+
function createServer(options = {}) {
|
|
2717
|
+
const app = express();
|
|
2718
|
+
const bridge = createCodexBridgeMiddleware();
|
|
2719
|
+
const authSession = options.password ? createAuthSession(options.password) : null;
|
|
2720
|
+
if (authSession) {
|
|
2721
|
+
app.use(authSession.middleware);
|
|
2722
|
+
}
|
|
2723
|
+
app.use(bridge);
|
|
2724
|
+
app.get("/codex-local-image", (req, res) => {
|
|
2725
|
+
const rawPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
2726
|
+
const localPath = normalizeLocalImagePath(rawPath);
|
|
2727
|
+
if (!localPath || !isAbsolute2(localPath)) {
|
|
2728
|
+
res.status(400).json({ error: "Expected absolute local file path." });
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
const contentType = IMAGE_CONTENT_TYPES[extname2(localPath).toLowerCase()];
|
|
2732
|
+
if (!contentType) {
|
|
2733
|
+
res.status(415).json({ error: "Unsupported image type." });
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
res.type(contentType);
|
|
2737
|
+
res.setHeader("Cache-Control", "private, max-age=300");
|
|
2738
|
+
res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
|
|
2739
|
+
if (!error) return;
|
|
2740
|
+
if (!res.headersSent) res.status(404).json({ error: "Image file not found." });
|
|
2741
|
+
});
|
|
2742
|
+
});
|
|
2743
|
+
app.get("/codex-local-file", (req, res) => {
|
|
2744
|
+
const rawPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
2745
|
+
const localPath = normalizeLocalPath(rawPath);
|
|
2746
|
+
if (!localPath || !isAbsolute2(localPath)) {
|
|
2747
|
+
res.status(400).json({ error: "Expected absolute local file path." });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
2751
|
+
res.setHeader("Content-Disposition", "inline");
|
|
2752
|
+
res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
|
|
2753
|
+
if (!error) return;
|
|
2754
|
+
if (!res.headersSent) res.status(404).json({ error: "File not found." });
|
|
2755
|
+
});
|
|
2756
|
+
});
|
|
2757
|
+
app.get("/codex-local-browse/*path", async (req, res) => {
|
|
2758
|
+
const rawPath = readWildcardPathParam(req.params.path);
|
|
2759
|
+
const localPath = decodeBrowsePath(`/${rawPath}`);
|
|
2760
|
+
if (!localPath || !isAbsolute2(localPath)) {
|
|
2761
|
+
res.status(400).json({ error: "Expected absolute local file path." });
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
try {
|
|
2765
|
+
const fileStat = await stat4(localPath);
|
|
2766
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
2767
|
+
if (fileStat.isDirectory()) {
|
|
2768
|
+
const html = await createDirectoryListingHtml(localPath);
|
|
2769
|
+
res.status(200).type("text/html; charset=utf-8").send(html);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
|
|
2773
|
+
if (!error) return;
|
|
2774
|
+
if (!res.headersSent) res.status(404).json({ error: "File not found." });
|
|
2775
|
+
});
|
|
2776
|
+
} catch {
|
|
2777
|
+
res.status(404).json({ error: "File not found." });
|
|
2778
|
+
}
|
|
2779
|
+
});
|
|
2780
|
+
app.get("/codex-local-edit/*path", async (req, res) => {
|
|
2781
|
+
const rawPath = readWildcardPathParam(req.params.path);
|
|
2782
|
+
const localPath = decodeBrowsePath(`/${rawPath}`);
|
|
2783
|
+
if (!localPath || !isAbsolute2(localPath)) {
|
|
2784
|
+
res.status(400).json({ error: "Expected absolute local file path." });
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
const fileStat = await stat4(localPath);
|
|
2789
|
+
if (!fileStat.isFile()) {
|
|
2790
|
+
res.status(400).json({ error: "Expected file path." });
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
const html = await createTextEditorHtml(localPath);
|
|
2794
|
+
res.status(200).type("text/html; charset=utf-8").send(html);
|
|
2795
|
+
} catch {
|
|
2796
|
+
res.status(404).json({ error: "File not found." });
|
|
2797
|
+
}
|
|
2798
|
+
});
|
|
2799
|
+
app.put("/codex-local-edit/*path", express.text({ type: "*/*", limit: "10mb" }), async (req, res) => {
|
|
2800
|
+
const rawPath = readWildcardPathParam(req.params.path);
|
|
2801
|
+
const localPath = decodeBrowsePath(`/${rawPath}`);
|
|
2802
|
+
if (!localPath || !isAbsolute2(localPath)) {
|
|
2803
|
+
res.status(400).json({ error: "Expected absolute local file path." });
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (!await isTextEditableFile(localPath)) {
|
|
2807
|
+
res.status(415).json({ error: "Only text-like files are editable." });
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
const body = typeof req.body === "string" ? req.body : "";
|
|
2811
|
+
try {
|
|
2812
|
+
await writeFile3(localPath, body, "utf8");
|
|
2813
|
+
res.status(200).json({ ok: true });
|
|
2814
|
+
} catch {
|
|
2815
|
+
res.status(404).json({ error: "File not found." });
|
|
2816
|
+
}
|
|
2817
|
+
});
|
|
2818
|
+
const hasFrontendAssets = existsSync2(spaEntryFile);
|
|
2819
|
+
if (hasFrontendAssets) {
|
|
2820
|
+
app.use(express.static(distDir));
|
|
2821
|
+
}
|
|
2822
|
+
app.use((_req, res) => {
|
|
2823
|
+
if (!hasFrontendAssets) {
|
|
2824
|
+
res.status(503).type("text/plain").send(
|
|
2825
|
+
[
|
|
2826
|
+
"Codex web UI assets are missing.",
|
|
2827
|
+
`Expected: ${spaEntryFile}`,
|
|
2828
|
+
"If running from source, build frontend assets with: npm run build:frontend",
|
|
2829
|
+
"If running with npx, clear the npx cache and reinstall @nervmor/codexui."
|
|
2830
|
+
].join("\n")
|
|
2831
|
+
);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
res.sendFile(spaEntryFile, (error) => {
|
|
2835
|
+
if (!error) return;
|
|
2836
|
+
if (!res.headersSent) {
|
|
2837
|
+
res.status(404).type("text/plain").send("Frontend entry file not found.");
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
});
|
|
2841
|
+
return {
|
|
2842
|
+
app,
|
|
2843
|
+
dispose: () => bridge.dispose(),
|
|
2844
|
+
attachWebSocket: (server) => {
|
|
2845
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2846
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2847
|
+
const url = new URL(req.url ?? "", "http://localhost");
|
|
2848
|
+
if (url.pathname !== "/codex-api/ws") {
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (authSession && !authSession.isRequestAuthorized(req)) {
|
|
2852
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
2853
|
+
socket.destroy();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2857
|
+
wss.emit("connection", ws, req);
|
|
2858
|
+
});
|
|
2859
|
+
});
|
|
2860
|
+
wss.on("connection", (ws) => {
|
|
2861
|
+
ws.send(JSON.stringify({ method: "ready", params: { ok: true }, atIso: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
2862
|
+
const unsubscribe = bridge.subscribeNotifications((notification) => {
|
|
2863
|
+
if (ws.readyState !== 1) return;
|
|
2864
|
+
ws.send(JSON.stringify(notification));
|
|
2865
|
+
});
|
|
2866
|
+
ws.on("close", unsubscribe);
|
|
2867
|
+
ws.on("error", unsubscribe);
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// src/server/password.ts
|
|
2874
|
+
import { randomInt } from "crypto";
|
|
2875
|
+
var CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
2876
|
+
function randomGroup(length) {
|
|
2877
|
+
let result = "";
|
|
2878
|
+
for (let i = 0; i < length; i++) {
|
|
2879
|
+
result += CHARS[randomInt(CHARS.length)];
|
|
2880
|
+
}
|
|
2881
|
+
return result;
|
|
2882
|
+
}
|
|
2883
|
+
function generatePassword() {
|
|
2884
|
+
return `${randomGroup(3)}-${randomGroup(3)}-${randomGroup(3)}`;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// src/cli/index.ts
|
|
2888
|
+
var program = new Command().name("codexui").description("Web interface for Codex app-server");
|
|
2889
|
+
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
2890
|
+
async function readCliVersion() {
|
|
2891
|
+
try {
|
|
2892
|
+
const packageJsonPath = join5(__dirname2, "..", "package.json");
|
|
2893
|
+
const raw = await readFile4(packageJsonPath, "utf8");
|
|
2894
|
+
const parsed = JSON.parse(raw);
|
|
2895
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
2896
|
+
} catch {
|
|
2897
|
+
return "unknown";
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
function isTermuxRuntime() {
|
|
2901
|
+
return Boolean(process.env.TERMUX_VERSION || process.env.PREFIX?.includes("/com.termux/"));
|
|
2902
|
+
}
|
|
2903
|
+
function canRun(command, args = []) {
|
|
2904
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
2905
|
+
return result.status === 0;
|
|
2906
|
+
}
|
|
2907
|
+
function runOrFail(command, args, label) {
|
|
2908
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
2909
|
+
if (result.status !== 0) {
|
|
2910
|
+
throw new Error(`${label} failed with exit code ${String(result.status ?? -1)}`);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
function runWithStatus(command, args) {
|
|
2914
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
2915
|
+
return result.status ?? -1;
|
|
2916
|
+
}
|
|
2917
|
+
function getUserNpmPrefix() {
|
|
2918
|
+
return join5(homedir3(), ".npm-global");
|
|
2919
|
+
}
|
|
2920
|
+
function resolveCodexCommand() {
|
|
2921
|
+
if (canRun("codex", ["--version"])) {
|
|
2922
|
+
return "codex";
|
|
2923
|
+
}
|
|
2924
|
+
const userCandidate = join5(getUserNpmPrefix(), "bin", "codex");
|
|
2925
|
+
if (existsSync3(userCandidate) && canRun(userCandidate, ["--version"])) {
|
|
2926
|
+
return userCandidate;
|
|
2927
|
+
}
|
|
2928
|
+
const prefix = process.env.PREFIX?.trim();
|
|
2929
|
+
if (!prefix) {
|
|
2930
|
+
return null;
|
|
2931
|
+
}
|
|
2932
|
+
const candidate = join5(prefix, "bin", "codex");
|
|
2933
|
+
if (existsSync3(candidate) && canRun(candidate, ["--version"])) {
|
|
2934
|
+
return candidate;
|
|
2935
|
+
}
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2938
|
+
function resolveCloudflaredCommand() {
|
|
2939
|
+
if (canRun("cloudflared", ["--version"])) {
|
|
2940
|
+
return "cloudflared";
|
|
2941
|
+
}
|
|
2942
|
+
const localCandidate = join5(homedir3(), ".local", "bin", "cloudflared");
|
|
2943
|
+
if (existsSync3(localCandidate) && canRun(localCandidate, ["--version"])) {
|
|
2944
|
+
return localCandidate;
|
|
2945
|
+
}
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
function mapCloudflaredLinuxArch(arch) {
|
|
2949
|
+
if (arch === "x64") {
|
|
2950
|
+
return "amd64";
|
|
2951
|
+
}
|
|
2952
|
+
if (arch === "arm64") {
|
|
2953
|
+
return "arm64";
|
|
2954
|
+
}
|
|
2955
|
+
return null;
|
|
2956
|
+
}
|
|
2957
|
+
function downloadFile(url, destination) {
|
|
2958
|
+
return new Promise((resolve2, reject) => {
|
|
2959
|
+
const request = (currentUrl) => {
|
|
2960
|
+
httpsGet(currentUrl, (response) => {
|
|
2961
|
+
const code = response.statusCode ?? 0;
|
|
2962
|
+
if (code >= 300 && code < 400 && response.headers.location) {
|
|
2963
|
+
response.resume();
|
|
2964
|
+
request(response.headers.location);
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
if (code !== 200) {
|
|
2968
|
+
response.resume();
|
|
2969
|
+
reject(new Error(`Download failed with HTTP status ${String(code)}`));
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
const file = createWriteStream(destination, { mode: 493 });
|
|
2973
|
+
response.pipe(file);
|
|
2974
|
+
file.on("finish", () => {
|
|
2975
|
+
file.close();
|
|
2976
|
+
resolve2();
|
|
2977
|
+
});
|
|
2978
|
+
file.on("error", reject);
|
|
2979
|
+
}).on("error", reject);
|
|
2980
|
+
};
|
|
2981
|
+
request(url);
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
async function ensureCloudflaredInstalledLinux() {
|
|
2985
|
+
const current = resolveCloudflaredCommand();
|
|
2986
|
+
if (current) {
|
|
2987
|
+
return current;
|
|
2988
|
+
}
|
|
2989
|
+
if (process.platform !== "linux") {
|
|
2990
|
+
return null;
|
|
2991
|
+
}
|
|
2992
|
+
const mappedArch = mapCloudflaredLinuxArch(process.arch);
|
|
2993
|
+
if (!mappedArch) {
|
|
2994
|
+
throw new Error(`cloudflared auto-install is not supported for Linux architecture: ${process.arch}`);
|
|
2995
|
+
}
|
|
2996
|
+
const userBinDir = join5(homedir3(), ".local", "bin");
|
|
2997
|
+
mkdirSync(userBinDir, { recursive: true });
|
|
2998
|
+
const destination = join5(userBinDir, "cloudflared");
|
|
2999
|
+
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${mappedArch}`;
|
|
3000
|
+
console.log("\ncloudflared not found. Installing to ~/.local/bin...\n");
|
|
3001
|
+
await downloadFile(downloadUrl, destination);
|
|
3002
|
+
chmodSync(destination, 493);
|
|
3003
|
+
process.env.PATH = `${userBinDir}:${process.env.PATH ?? ""}`;
|
|
3004
|
+
const installed = resolveCloudflaredCommand();
|
|
3005
|
+
if (!installed) {
|
|
3006
|
+
throw new Error("cloudflared download completed but executable is still not available");
|
|
3007
|
+
}
|
|
3008
|
+
console.log("\ncloudflared installed.\n");
|
|
3009
|
+
return installed;
|
|
3010
|
+
}
|
|
3011
|
+
async function shouldInstallCloudflaredInteractively() {
|
|
3012
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3013
|
+
console.warn("\n[cloudflared] cloudflared is missing and terminal is non-interactive, skipping install.");
|
|
3014
|
+
return false;
|
|
3015
|
+
}
|
|
3016
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
3017
|
+
try {
|
|
3018
|
+
const answer = await prompt.question("cloudflared is not installed. Install it now to ~/.local/bin? [y/N] ");
|
|
3019
|
+
const normalized = answer.trim().toLowerCase();
|
|
3020
|
+
return normalized === "y" || normalized === "yes";
|
|
3021
|
+
} finally {
|
|
3022
|
+
prompt.close();
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
async function resolveCloudflaredForTunnel() {
|
|
3026
|
+
const current = resolveCloudflaredCommand();
|
|
3027
|
+
if (current) {
|
|
3028
|
+
return current;
|
|
3029
|
+
}
|
|
3030
|
+
const installApproved = await shouldInstallCloudflaredInteractively();
|
|
3031
|
+
if (!installApproved) {
|
|
3032
|
+
return null;
|
|
3033
|
+
}
|
|
3034
|
+
return ensureCloudflaredInstalledLinux();
|
|
3035
|
+
}
|
|
3036
|
+
function hasCodexAuth() {
|
|
3037
|
+
const codexHome = process.env.CODEX_HOME?.trim() || join5(homedir3(), ".codex");
|
|
3038
|
+
return existsSync3(join5(codexHome, "auth.json"));
|
|
3039
|
+
}
|
|
3040
|
+
function ensureCodexInstalled() {
|
|
3041
|
+
let codexCommand = resolveCodexCommand();
|
|
3042
|
+
if (!codexCommand) {
|
|
3043
|
+
const installWithFallback = (pkg, label) => {
|
|
3044
|
+
const status = runWithStatus("npm", ["install", "-g", pkg]);
|
|
3045
|
+
if (status === 0) {
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
if (isTermuxRuntime()) {
|
|
3049
|
+
throw new Error(`${label} failed with exit code ${String(status)}`);
|
|
3050
|
+
}
|
|
3051
|
+
const userPrefix = getUserNpmPrefix();
|
|
3052
|
+
console.log(`
|
|
3053
|
+
Global npm install requires elevated permissions. Retrying with --prefix ${userPrefix}...
|
|
3054
|
+
`);
|
|
3055
|
+
runOrFail("npm", ["install", "-g", "--prefix", userPrefix, pkg], `${label} (user prefix)`);
|
|
3056
|
+
process.env.PATH = `${join5(userPrefix, "bin")}:${process.env.PATH ?? ""}`;
|
|
3057
|
+
};
|
|
3058
|
+
if (isTermuxRuntime()) {
|
|
3059
|
+
console.log("\nCodex CLI not found. Installing Termux-compatible Codex CLI from npm...\n");
|
|
3060
|
+
installWithFallback("@mmmbuto/codex-cli-termux", "Codex CLI install");
|
|
3061
|
+
codexCommand = resolveCodexCommand();
|
|
3062
|
+
if (!codexCommand) {
|
|
3063
|
+
console.log("\nTermux npm package did not expose `codex`. Installing official CLI fallback...\n");
|
|
3064
|
+
installWithFallback("@openai/codex", "Codex CLI fallback install");
|
|
3065
|
+
}
|
|
3066
|
+
} else {
|
|
3067
|
+
console.log("\nCodex CLI not found. Installing official Codex CLI from npm...\n");
|
|
3068
|
+
installWithFallback("@openai/codex", "Codex CLI install");
|
|
3069
|
+
}
|
|
3070
|
+
codexCommand = resolveCodexCommand();
|
|
3071
|
+
if (!codexCommand && !isTermuxRuntime()) {
|
|
3072
|
+
throw new Error("Official Codex CLI install completed but binary is still not available in PATH");
|
|
3073
|
+
}
|
|
3074
|
+
if (!codexCommand && isTermuxRuntime()) {
|
|
3075
|
+
codexCommand = resolveCodexCommand();
|
|
3076
|
+
}
|
|
3077
|
+
if (!codexCommand) {
|
|
3078
|
+
throw new Error("Codex CLI install completed but binary is still not available in PATH");
|
|
3079
|
+
}
|
|
3080
|
+
console.log("\nCodex CLI installed.\n");
|
|
3081
|
+
}
|
|
3082
|
+
return codexCommand;
|
|
3083
|
+
}
|
|
3084
|
+
function resolvePassword(input) {
|
|
3085
|
+
if (input === false) {
|
|
3086
|
+
return void 0;
|
|
3087
|
+
}
|
|
3088
|
+
if (typeof input === "string") {
|
|
3089
|
+
return input;
|
|
3090
|
+
}
|
|
3091
|
+
return generatePassword();
|
|
3092
|
+
}
|
|
3093
|
+
function printTermuxKeepAlive(lines) {
|
|
3094
|
+
if (!isTermuxRuntime()) {
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
lines.push("");
|
|
3098
|
+
lines.push(" Android/Termux keep-alive:");
|
|
3099
|
+
lines.push(" 1) Keep this Termux session open (do not swipe it away).");
|
|
3100
|
+
lines.push(" 2) Disable battery optimization for Termux in Android settings.");
|
|
3101
|
+
lines.push(" 3) Optional: run `termux-wake-lock` in another shell.");
|
|
3102
|
+
}
|
|
3103
|
+
function openBrowser(url) {
|
|
3104
|
+
const command = process.platform === "darwin" ? { cmd: "open", args: [url] } : process.platform === "win32" ? { cmd: "cmd", args: ["/c", "start", "", url] } : { cmd: "xdg-open", args: [url] };
|
|
3105
|
+
const child = spawn3(command.cmd, command.args, { detached: true, stdio: "ignore" });
|
|
3106
|
+
child.on("error", () => {
|
|
3107
|
+
});
|
|
3108
|
+
child.unref();
|
|
3109
|
+
}
|
|
3110
|
+
function parseCloudflaredUrl(chunk) {
|
|
3111
|
+
const urlMatch = chunk.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/g);
|
|
3112
|
+
if (!urlMatch || urlMatch.length === 0) {
|
|
3113
|
+
return null;
|
|
3114
|
+
}
|
|
3115
|
+
return urlMatch[urlMatch.length - 1] ?? null;
|
|
3116
|
+
}
|
|
3117
|
+
function getAccessibleUrls(port) {
|
|
3118
|
+
const urls = /* @__PURE__ */ new Set([`http://localhost:${String(port)}`]);
|
|
3119
|
+
const interfaces = networkInterfaces();
|
|
3120
|
+
for (const entries of Object.values(interfaces)) {
|
|
3121
|
+
if (!entries) {
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
for (const entry of entries) {
|
|
3125
|
+
if (entry.internal) {
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
if (entry.family === "IPv4") {
|
|
3129
|
+
urls.add(`http://${entry.address}:${String(port)}`);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
return Array.from(urls);
|
|
3134
|
+
}
|
|
3135
|
+
async function startCloudflaredTunnel(command, localPort) {
|
|
3136
|
+
return new Promise((resolve2, reject) => {
|
|
3137
|
+
const child = spawn3(command, ["tunnel", "--url", `http://localhost:${String(localPort)}`], {
|
|
3138
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3139
|
+
});
|
|
3140
|
+
const timeout = setTimeout(() => {
|
|
3141
|
+
child.kill("SIGTERM");
|
|
3142
|
+
reject(new Error("Timed out waiting for cloudflared tunnel URL"));
|
|
3143
|
+
}, 2e4);
|
|
3144
|
+
const handleData = (value) => {
|
|
3145
|
+
const text = String(value);
|
|
3146
|
+
const parsedUrl = parseCloudflaredUrl(text);
|
|
3147
|
+
if (!parsedUrl) {
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
clearTimeout(timeout);
|
|
3151
|
+
child.stdout?.off("data", handleData);
|
|
3152
|
+
child.stderr?.off("data", handleData);
|
|
3153
|
+
resolve2({ process: child, url: parsedUrl });
|
|
3154
|
+
};
|
|
3155
|
+
const onError = (error) => {
|
|
3156
|
+
clearTimeout(timeout);
|
|
3157
|
+
reject(new Error(`Failed to start cloudflared: ${error.message}`));
|
|
3158
|
+
};
|
|
3159
|
+
child.once("error", onError);
|
|
3160
|
+
child.stdout?.on("data", handleData);
|
|
3161
|
+
child.stderr?.on("data", handleData);
|
|
3162
|
+
child.once("exit", (code) => {
|
|
3163
|
+
if (code === 0) {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
clearTimeout(timeout);
|
|
3167
|
+
reject(new Error(`cloudflared exited before providing a URL (code ${String(code)})`));
|
|
3168
|
+
});
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
function listenWithFallback(server, startPort) {
|
|
3172
|
+
return new Promise((resolve2, reject) => {
|
|
3173
|
+
const attempt = (port) => {
|
|
3174
|
+
const onError = (error) => {
|
|
3175
|
+
server.off("listening", onListening);
|
|
3176
|
+
if (error.code === "EADDRINUSE" || error.code === "EACCES") {
|
|
3177
|
+
attempt(port + 1);
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
reject(error);
|
|
3181
|
+
};
|
|
3182
|
+
const onListening = () => {
|
|
3183
|
+
server.off("error", onError);
|
|
3184
|
+
resolve2(port);
|
|
3185
|
+
};
|
|
3186
|
+
server.once("error", onError);
|
|
3187
|
+
server.once("listening", onListening);
|
|
3188
|
+
server.listen(port, "0.0.0.0");
|
|
3189
|
+
};
|
|
3190
|
+
attempt(startPort);
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
async function startServer(options) {
|
|
3194
|
+
const version = await readCliVersion();
|
|
3195
|
+
const codexCommand = ensureCodexInstalled() ?? resolveCodexCommand();
|
|
3196
|
+
if (!hasCodexAuth() && codexCommand) {
|
|
3197
|
+
console.log("\nCodex is not logged in. Starting `codex login`...\n");
|
|
3198
|
+
runOrFail(codexCommand, ["login"], "Codex login");
|
|
3199
|
+
}
|
|
3200
|
+
const requestedPort = parseInt(options.port, 10);
|
|
3201
|
+
const password = resolvePassword(options.password);
|
|
3202
|
+
const { app, dispose, attachWebSocket } = createServer({ password });
|
|
3203
|
+
const server = createServer2(app);
|
|
3204
|
+
attachWebSocket(server);
|
|
3205
|
+
const port = await listenWithFallback(server, requestedPort);
|
|
3206
|
+
let tunnelChild = null;
|
|
3207
|
+
let tunnelUrl = null;
|
|
3208
|
+
if (options.tunnel) {
|
|
3209
|
+
try {
|
|
3210
|
+
const cloudflaredCommand = await resolveCloudflaredForTunnel();
|
|
3211
|
+
if (!cloudflaredCommand) {
|
|
3212
|
+
throw new Error("cloudflared is not installed");
|
|
3213
|
+
}
|
|
3214
|
+
const tunnel = await startCloudflaredTunnel(cloudflaredCommand, port);
|
|
3215
|
+
tunnelChild = tunnel.process;
|
|
3216
|
+
tunnelUrl = tunnel.url;
|
|
3217
|
+
} catch (error) {
|
|
3218
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3219
|
+
console.warn(`
|
|
3220
|
+
[cloudflared] Tunnel not started: ${message}`);
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
const lines = [
|
|
3224
|
+
"",
|
|
3225
|
+
"Codex Web Local is running!",
|
|
3226
|
+
` Version: ${version}`,
|
|
3227
|
+
" npm: https://www.npmjs.com/package/@nervmor/codexui",
|
|
3228
|
+
"",
|
|
3229
|
+
` Bind: http://0.0.0.0:${String(port)}`
|
|
3230
|
+
];
|
|
3231
|
+
const accessUrls = getAccessibleUrls(port);
|
|
3232
|
+
if (accessUrls.length > 0) {
|
|
3233
|
+
lines.push(` Local: ${accessUrls[0]}`);
|
|
3234
|
+
for (const accessUrl of accessUrls.slice(1)) {
|
|
3235
|
+
lines.push(` Network: ${accessUrl}`);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
if (port !== requestedPort) {
|
|
3239
|
+
lines.push(` Requested port ${String(requestedPort)} was unavailable; using ${String(port)}.`);
|
|
3240
|
+
}
|
|
3241
|
+
if (password) {
|
|
3242
|
+
lines.push(` Password: ${password}`);
|
|
3243
|
+
}
|
|
3244
|
+
if (tunnelUrl) {
|
|
3245
|
+
lines.push(` Tunnel: ${tunnelUrl}`);
|
|
3246
|
+
lines.push(" Tunnel QR code below");
|
|
3247
|
+
}
|
|
3248
|
+
printTermuxKeepAlive(lines);
|
|
3249
|
+
lines.push("");
|
|
3250
|
+
console.log(lines.join("\n"));
|
|
3251
|
+
if (tunnelUrl) {
|
|
3252
|
+
qrcode.generate(tunnelUrl, { small: true });
|
|
3253
|
+
console.log("");
|
|
3254
|
+
}
|
|
3255
|
+
openBrowser(`http://localhost:${String(port)}`);
|
|
3256
|
+
function shutdown() {
|
|
3257
|
+
console.log("\nShutting down...");
|
|
3258
|
+
if (tunnelChild && !tunnelChild.killed) {
|
|
3259
|
+
tunnelChild.kill("SIGTERM");
|
|
3260
|
+
}
|
|
3261
|
+
server.close(() => {
|
|
3262
|
+
dispose();
|
|
3263
|
+
process.exit(0);
|
|
3264
|
+
});
|
|
3265
|
+
setTimeout(() => {
|
|
3266
|
+
dispose();
|
|
3267
|
+
process.exit(1);
|
|
3268
|
+
}, 5e3).unref();
|
|
3269
|
+
}
|
|
3270
|
+
process.on("SIGINT", shutdown);
|
|
3271
|
+
process.on("SIGTERM", shutdown);
|
|
3272
|
+
}
|
|
3273
|
+
async function runLogin() {
|
|
3274
|
+
const codexCommand = ensureCodexInstalled() ?? "codex";
|
|
3275
|
+
console.log("\nStarting `codex login`...\n");
|
|
3276
|
+
runOrFail(codexCommand, ["login"], "Codex login");
|
|
3277
|
+
}
|
|
3278
|
+
program.option("-p, --port <port>", "port to listen on", "5999").option("--password <pass>", "set a specific password").option("--no-password", "disable password protection").option("--tunnel", "start cloudflared tunnel", true).option("--no-tunnel", "disable cloudflared tunnel startup").action(async (opts) => {
|
|
3279
|
+
await startServer(opts);
|
|
3280
|
+
});
|
|
3281
|
+
program.command("login").description("Install/check Codex CLI and run `codex login`").action(runLogin);
|
|
3282
|
+
program.command("help").description("Show codexui command help").action(() => {
|
|
3283
|
+
program.outputHelp();
|
|
3284
|
+
});
|
|
3285
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
3286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3287
|
+
console.error(`
|
|
3288
|
+
Failed to run codexui: ${message}`);
|
|
3289
|
+
process.exit(1);
|
|
3290
|
+
});
|
|
3291
|
+
//# sourceMappingURL=index.js.map
|