minecodex 0.1.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 +21 -0
- package/README.md +56 -0
- package/apps/dashboard/app.js +387 -0
- package/apps/dashboard/favicon.svg +18 -0
- package/apps/dashboard/index.html +70 -0
- package/apps/dashboard/styles.css +297 -0
- package/features/images/README.md +106 -0
- package/features/images/codex-feature.json +24 -0
- package/features/images/src/http-server.mjs +302 -0
- package/features/images/src/image-library.mjs +582 -0
- package/features/images/src/main.mjs +52 -0
- package/features/images/src/prompt-index.mjs +182 -0
- package/features/images/src/save-as.mjs +40 -0
- package/features/images/src/thread-metadata.mjs +93 -0
- package/features/images/web/app.js +1045 -0
- package/features/images/web/index.html +162 -0
- package/features/images/web/styles.css +994 -0
- package/features/model-slider/README.md +30 -0
- package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
- package/features/model-slider/assets/providers/anthropic.svg +1 -0
- package/features/model-slider/assets/providers/deepseek.svg +1 -0
- package/features/model-slider/assets/providers/gemini.svg +1 -0
- package/features/model-slider/assets/providers/grok.svg +1 -0
- package/features/model-slider/assets/providers/hunyuan.svg +1 -0
- package/features/model-slider/assets/providers/kimi.svg +1 -0
- package/features/model-slider/assets/providers/minimax.svg +1 -0
- package/features/model-slider/assets/providers/openai.svg +1 -0
- package/features/model-slider/assets/providers/qwen.svg +1 -0
- package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
- package/features/model-slider/assets/providers/zhipu.svg +1 -0
- package/features/model-slider/assets/ui/sparkles.svg +18 -0
- package/features/model-slider/assets/ui/star-off.svg +17 -0
- package/features/model-slider/assets/ui/star.svg +15 -0
- package/features/model-slider/codex-feature.json +56 -0
- package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
- package/features/model-slider/vendor/lucide-LICENSE +35 -0
- package/features/notes/README.md +106 -0
- package/features/notes/assets/icons/circle.svg +15 -0
- package/features/notes/assets/icons/ellipsis.svg +17 -0
- package/features/notes/assets/icons/external-link.svg +17 -0
- package/features/notes/assets/icons/list-todo.svg +19 -0
- package/features/notes/assets/icons/message-square-quote.svg +17 -0
- package/features/notes/assets/icons/paperclip.svg +15 -0
- package/features/notes/assets/icons/star-off.svg +17 -0
- package/features/notes/assets/icons/star.svg +15 -0
- package/features/notes/assets/icons/sticky-note.svg +16 -0
- package/features/notes/assets/icons/trash-2.svg +19 -0
- package/features/notes/codex-feature.json +90 -0
- package/features/notes/src/http-server.mjs +541 -0
- package/features/notes/src/main.mjs +54 -0
- package/features/notes/src/product.mjs +771 -0
- package/features/notes/web/app.js +1495 -0
- package/features/notes/web/codex-artifact-document.svg +1 -0
- package/features/notes/web/codex-attachment.svg +1 -0
- package/features/notes/web/codex-build.svg +1 -0
- package/features/notes/web/codex-code.svg +1 -0
- package/features/notes/web/codex-cplusplus.svg +1 -0
- package/features/notes/web/codex-css.svg +1 -0
- package/features/notes/web/codex-document.svg +1 -0
- package/features/notes/web/codex-file.svg +1 -0
- package/features/notes/web/codex-folder.svg +1 -0
- package/features/notes/web/codex-hashes.svg +1 -0
- package/features/notes/web/codex-html.svg +1 -0
- package/features/notes/web/codex-image.svg +1 -0
- package/features/notes/web/codex-java.svg +1 -0
- package/features/notes/web/codex-javascript.svg +1 -0
- package/features/notes/web/codex-json.svg +1 -0
- package/features/notes/web/codex-notebook.svg +1 -0
- package/features/notes/web/codex-pdf.svg +1 -0
- package/features/notes/web/codex-pencil.svg +3 -0
- package/features/notes/web/codex-php.svg +1 -0
- package/features/notes/web/codex-plus.svg +3 -0
- package/features/notes/web/codex-presentation.svg +1 -0
- package/features/notes/web/codex-python.svg +1 -0
- package/features/notes/web/codex-react.svg +1 -0
- package/features/notes/web/codex-rust.svg +1 -0
- package/features/notes/web/codex-shell.svg +1 -0
- package/features/notes/web/codex-skill.svg +1 -0
- package/features/notes/web/codex-spreadsheet.svg +1 -0
- package/features/notes/web/codex-task.svg +3 -0
- package/features/notes/web/codex-terminal.svg +1 -0
- package/features/notes/web/codex-toml.svg +1 -0
- package/features/notes/web/codex-typescript.svg +1 -0
- package/features/notes/web/codex-view-all.svg +3 -0
- package/features/notes/web/index.html +15 -0
- package/features/notes/web/styles.css +1155 -0
- package/package.json +67 -0
- package/packages/cli/bin/mcx.mjs +7 -0
- package/packages/cli/src/browser-exchange.mjs +31 -0
- package/packages/cli/src/commands.mjs +389 -0
- package/packages/cli/src/config.mjs +101 -0
- package/packages/cli/src/control-server.mjs +371 -0
- package/packages/cli/src/npm-adapter.mjs +40 -0
- package/packages/cli/src/paths.mjs +33 -0
- package/packages/cli/src/platform.mjs +368 -0
- package/packages/cli/src/runtime-manager.mjs +338 -0
- package/packages/runtime-host/README.md +171 -0
- package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
- package/packages/runtime-host/src/feature-registry.mjs +827 -0
- package/packages/runtime-host/src/main.mjs +98 -0
- package/packages/runtime-host/src/pid-file.mjs +23 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "notes",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"label": {
|
|
6
|
+
"en": "Notes",
|
|
7
|
+
"zh-CN": "笔记"
|
|
8
|
+
},
|
|
9
|
+
"description": {
|
|
10
|
+
"en": "Manage cross-session todos, reusable prompts, and files from the Codex toolbar.",
|
|
11
|
+
"zh-CN": "在 Codex 中跨会话管理待办、常用提示词和文件,并快速复用。"
|
|
12
|
+
},
|
|
13
|
+
"surfaceUrl": "http://127.0.0.1:47832/?surface=summary",
|
|
14
|
+
"healthUrl": "http://127.0.0.1:47832/api/health",
|
|
15
|
+
"entry": {
|
|
16
|
+
"kind": "conversation-toolbar",
|
|
17
|
+
"ariaLabel": {
|
|
18
|
+
"en": "Toggle pinned notes",
|
|
19
|
+
"zh-CN": "切换固定笔记"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"pinnedSummary": {
|
|
23
|
+
"label": {
|
|
24
|
+
"en": "Pinned Notes",
|
|
25
|
+
"zh-CN": "固定笔记"
|
|
26
|
+
},
|
|
27
|
+
"presentation": "responsive-summary",
|
|
28
|
+
"overlayLabel": {
|
|
29
|
+
"en": "Toggle notes",
|
|
30
|
+
"zh-CN": "切换笔记"
|
|
31
|
+
},
|
|
32
|
+
"pinnedLabel": {
|
|
33
|
+
"en": "Toggle pinned notes",
|
|
34
|
+
"zh-CN": "切换固定笔记"
|
|
35
|
+
},
|
|
36
|
+
"surfaceUrl": "http://127.0.0.1:47832/?surface=summary"
|
|
37
|
+
},
|
|
38
|
+
"detailTabs": [
|
|
39
|
+
{
|
|
40
|
+
"id": "todo",
|
|
41
|
+
"label": {
|
|
42
|
+
"en": "Todo",
|
|
43
|
+
"zh-CN": "待办"
|
|
44
|
+
},
|
|
45
|
+
"surfaceUrl": "http://127.0.0.1:47832/?surface=todo",
|
|
46
|
+
"icon": {
|
|
47
|
+
"resource": "assets/icons/list-todo.svg"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "prompts",
|
|
52
|
+
"label": {
|
|
53
|
+
"en": "Prompts",
|
|
54
|
+
"zh-CN": "提示词"
|
|
55
|
+
},
|
|
56
|
+
"surfaceUrl": "http://127.0.0.1:47832/?surface=prompts",
|
|
57
|
+
"icon": {
|
|
58
|
+
"resource": "assets/icons/message-square-quote.svg"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "files",
|
|
63
|
+
"label": {
|
|
64
|
+
"en": "Files",
|
|
65
|
+
"zh-CN": "文件"
|
|
66
|
+
},
|
|
67
|
+
"surfaceUrl": "http://127.0.0.1:47832/?surface=files",
|
|
68
|
+
"icon": {
|
|
69
|
+
"resource": "assets/icons/paperclip.svg"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"hostActions": [
|
|
74
|
+
"insert-text",
|
|
75
|
+
"attach-file",
|
|
76
|
+
"open-detail-tab",
|
|
77
|
+
"open-editor-modal",
|
|
78
|
+
"resolve-file-paths"
|
|
79
|
+
],
|
|
80
|
+
"placement": {
|
|
81
|
+
"order": 20
|
|
82
|
+
},
|
|
83
|
+
"command": [
|
|
84
|
+
"node",
|
|
85
|
+
"src/main.mjs"
|
|
86
|
+
],
|
|
87
|
+
"icon": {
|
|
88
|
+
"resource": "assets/icons/sticky-note.svg"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { ProductError } from "./product.mjs";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const DEFAULT_WEB_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../web");
|
|
14
|
+
const LUCIDE_ICON_ROOT = path.resolve(DEFAULT_WEB_ROOT, "../assets/icons");
|
|
15
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
16
|
+
const MAX_PREVIEW_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
18
|
+
const LUCIDE_ICON_NAMES = [
|
|
19
|
+
"circle",
|
|
20
|
+
"ellipsis",
|
|
21
|
+
"external-link",
|
|
22
|
+
"list-todo",
|
|
23
|
+
"message-square-quote",
|
|
24
|
+
"paperclip",
|
|
25
|
+
"star",
|
|
26
|
+
"star-off",
|
|
27
|
+
"trash-2",
|
|
28
|
+
];
|
|
29
|
+
const THIN_LUCIDE_ICON_NAMES = new Set([
|
|
30
|
+
"ellipsis",
|
|
31
|
+
"external-link",
|
|
32
|
+
"list-todo",
|
|
33
|
+
"message-square-quote",
|
|
34
|
+
"paperclip",
|
|
35
|
+
"star",
|
|
36
|
+
"star-off",
|
|
37
|
+
"trash-2",
|
|
38
|
+
]);
|
|
39
|
+
const CODEX_FILE_ICON_KINDS = [
|
|
40
|
+
"artifact-document",
|
|
41
|
+
"build",
|
|
42
|
+
"code",
|
|
43
|
+
"cplusplus",
|
|
44
|
+
"css",
|
|
45
|
+
"document",
|
|
46
|
+
"file",
|
|
47
|
+
"folder",
|
|
48
|
+
"hashes",
|
|
49
|
+
"html",
|
|
50
|
+
"image",
|
|
51
|
+
"java",
|
|
52
|
+
"javascript",
|
|
53
|
+
"json",
|
|
54
|
+
"notebook",
|
|
55
|
+
"pdf",
|
|
56
|
+
"php",
|
|
57
|
+
"presentation",
|
|
58
|
+
"python",
|
|
59
|
+
"react",
|
|
60
|
+
"rust",
|
|
61
|
+
"shell",
|
|
62
|
+
"skill",
|
|
63
|
+
"spreadsheet",
|
|
64
|
+
"terminal",
|
|
65
|
+
"toml",
|
|
66
|
+
"typescript",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function pickFilesJxa(locale) {
|
|
70
|
+
const prompt = locale === "zh-CN" ? "将文件固定到 CodexNotes" : "Pin files to CodexNotes";
|
|
71
|
+
return String.raw`
|
|
72
|
+
const app = Application.currentApplication();
|
|
73
|
+
app.includeStandardAdditions = true;
|
|
74
|
+
const selected = app.chooseFile({
|
|
75
|
+
withPrompt: ${JSON.stringify(prompt)},
|
|
76
|
+
multipleSelectionsAllowed: true,
|
|
77
|
+
invisibles: false,
|
|
78
|
+
});
|
|
79
|
+
JSON.stringify(selected.map((item) => item.toString()));
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const STATIC_FILES = new Map([
|
|
84
|
+
["/", { file: "index.html", type: "text/html; charset=utf-8" }],
|
|
85
|
+
["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }],
|
|
86
|
+
["/styles.css", { file: "styles.css", type: "text/css; charset=utf-8" }],
|
|
87
|
+
...LUCIDE_ICON_NAMES.map((name) => [
|
|
88
|
+
`/icons/${name}.svg`,
|
|
89
|
+
{
|
|
90
|
+
absoluteFile: path.join(LUCIDE_ICON_ROOT, `${name}.svg`),
|
|
91
|
+
type: "image/svg+xml",
|
|
92
|
+
opticalStrokeWidth: THIN_LUCIDE_ICON_NAMES.has(name) ? "1.5" : null,
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
["/icons/circle-check.svg", { file: "codex-task.svg", type: "image/svg+xml" }],
|
|
96
|
+
["/icons/attachment.svg", { file: "codex-attachment.svg", type: "image/svg+xml" }],
|
|
97
|
+
["/icons/edit-pencil.svg", { file: "codex-pencil.svg", type: "image/svg+xml" }],
|
|
98
|
+
// Extracted from the Codex Summary section action so the Add silhouette and
|
|
99
|
+
// optical weight match the host instead of Lucide's heavier stroked plus.
|
|
100
|
+
["/icons/plus.svg", { file: "codex-plus.svg", type: "image/svg+xml" }],
|
|
101
|
+
...CODEX_FILE_ICON_KINDS.map((kind) => [
|
|
102
|
+
`/icons/file-${kind}.svg`,
|
|
103
|
+
{ file: `codex-${kind}.svg`, type: "image/svg+xml" },
|
|
104
|
+
]),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
function sendJson(response, status, payload) {
|
|
108
|
+
const body = JSON.stringify(payload);
|
|
109
|
+
response.writeHead(status, {
|
|
110
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
111
|
+
"Content-Length": Buffer.byteLength(body),
|
|
112
|
+
"Cache-Control": "no-store",
|
|
113
|
+
});
|
|
114
|
+
response.end(body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sendEmpty(response, status = 204) {
|
|
118
|
+
response.writeHead(status, { "Cache-Control": "no-store" });
|
|
119
|
+
response.end();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readJson(request) {
|
|
123
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
124
|
+
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
125
|
+
throw new ProductError("Content-Type must be application/json", {
|
|
126
|
+
code: "INVALID_CONTENT_TYPE",
|
|
127
|
+
status: 415,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
for await (const chunk of request) {
|
|
133
|
+
size += chunk.length;
|
|
134
|
+
if (size > MAX_BODY_BYTES) {
|
|
135
|
+
throw new ProductError("Request body is too large", {
|
|
136
|
+
code: "BODY_TOO_LARGE",
|
|
137
|
+
status: 413,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
chunks.push(chunk);
|
|
141
|
+
}
|
|
142
|
+
if (size === 0) return {};
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
146
|
+
} catch {
|
|
147
|
+
throw new ProductError("Request body must be valid JSON", { code: "INVALID_JSON" });
|
|
148
|
+
}
|
|
149
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
150
|
+
throw new ProductError("Request body must be a JSON object", { code: "INVALID_BODY" });
|
|
151
|
+
}
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function idFrom(pathname, prefix, suffix = "") {
|
|
156
|
+
if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) return null;
|
|
157
|
+
const raw = pathname.slice(prefix.length, suffix ? -suffix.length : undefined);
|
|
158
|
+
if (!raw || raw.includes("/")) return null;
|
|
159
|
+
let decoded;
|
|
160
|
+
try {
|
|
161
|
+
decoded = decodeURIComponent(raw);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new ProductError("Route id must use valid URL encoding", { code: "INVALID_ID" });
|
|
164
|
+
}
|
|
165
|
+
if (!decoded || decoded.includes("/")) {
|
|
166
|
+
throw new ProductError("Route id is invalid", { code: "INVALID_ID" });
|
|
167
|
+
}
|
|
168
|
+
return decoded;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizedLocale(value) {
|
|
172
|
+
return String(value ?? "").toLowerCase().startsWith("zh") ? "zh-CN" : "en";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requestLocale(request) {
|
|
176
|
+
return normalizedLocale(request.headers["accept-language"]?.split(",", 1)[0]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function chooseFiles({ locale = "en" } = {}) {
|
|
180
|
+
if (process.platform !== "darwin") {
|
|
181
|
+
throw new ProductError("The system picker is currently available on macOS", {
|
|
182
|
+
code: "PICKER_UNAVAILABLE",
|
|
183
|
+
status: 501,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const { stdout } = await execFileAsync("osascript", ["-l", "JavaScript", "-e", pickFilesJxa(locale)], {
|
|
188
|
+
maxBuffer: 1024 * 1024,
|
|
189
|
+
});
|
|
190
|
+
const paths = JSON.parse(stdout.trim() || "[]");
|
|
191
|
+
return { cancelled: false, paths };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (String(error.stderr ?? error.message).includes("-128")) {
|
|
194
|
+
return { cancelled: true, paths: [] };
|
|
195
|
+
}
|
|
196
|
+
throw new ProductError("The system file picker could not be opened", {
|
|
197
|
+
code: "PICKER_FAILED",
|
|
198
|
+
status: 503,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function revealFileInSystemManager(filePath, {
|
|
204
|
+
platform = process.platform,
|
|
205
|
+
executeFile = execFileAsync,
|
|
206
|
+
} = {}) {
|
|
207
|
+
let command;
|
|
208
|
+
let args;
|
|
209
|
+
if (platform === "darwin") {
|
|
210
|
+
command = "/usr/bin/open";
|
|
211
|
+
args = ["-R", filePath];
|
|
212
|
+
} else if (platform === "win32") {
|
|
213
|
+
command = "explorer.exe";
|
|
214
|
+
args = ["/select,", filePath];
|
|
215
|
+
} else {
|
|
216
|
+
throw new ProductError("Reveal in the system file manager is not available on this platform", {
|
|
217
|
+
code: "REVEAL_UNAVAILABLE",
|
|
218
|
+
status: 501,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await executeFile(command, args);
|
|
223
|
+
return { revealed: true };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new ProductError("The file could not be revealed in the system file manager", {
|
|
226
|
+
code: error.code ?? "REVEAL_FAILED",
|
|
227
|
+
status: 503,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatHost(host) {
|
|
233
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateMutationOrigin(request, address, host) {
|
|
237
|
+
const expectedHost = formatHost(host);
|
|
238
|
+
const expectedOrigin = `http://${expectedHost}:${address.port}`;
|
|
239
|
+
const expectedHostHeader = new URL(expectedOrigin).host;
|
|
240
|
+
if (request.headers.host !== expectedHostHeader) {
|
|
241
|
+
throw new ProductError("Mutation requests are restricted to the exact bound loopback Host", {
|
|
242
|
+
code: "HOST_NOT_ALLOWED",
|
|
243
|
+
status: 403,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const origin = request.headers.origin;
|
|
248
|
+
if (!origin) return;
|
|
249
|
+
let parsed;
|
|
250
|
+
try {
|
|
251
|
+
parsed = new URL(origin);
|
|
252
|
+
} catch {
|
|
253
|
+
throw new ProductError("Request origin is invalid", { code: "INVALID_ORIGIN", status: 403 });
|
|
254
|
+
}
|
|
255
|
+
if (parsed.origin !== expectedOrigin) {
|
|
256
|
+
throw new ProductError("Mutation requests are restricted to this local CodexNotes origin", {
|
|
257
|
+
code: "ORIGIN_NOT_ALLOWED",
|
|
258
|
+
status: 403,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function createCodexNotesServer({
|
|
264
|
+
product,
|
|
265
|
+
host = "127.0.0.1",
|
|
266
|
+
port = 47832,
|
|
267
|
+
webRoot = DEFAULT_WEB_ROOT,
|
|
268
|
+
filePicker = chooseFiles,
|
|
269
|
+
fileRevealer = revealFileInSystemManager,
|
|
270
|
+
instanceId = process.env.MINECODEX_FEATURE_INSTANCE_ID ?? "unconfigured",
|
|
271
|
+
} = {}) {
|
|
272
|
+
if (!product) throw new Error("product is required");
|
|
273
|
+
if (!LOOPBACK_HOSTS.has(host)) {
|
|
274
|
+
throw new Error("CodexNotes only supports loopback hosts (127.0.0.1, localhost, or ::1)");
|
|
275
|
+
}
|
|
276
|
+
const eventClients = new Set();
|
|
277
|
+
let heartbeat = null;
|
|
278
|
+
|
|
279
|
+
function broadcast(change) {
|
|
280
|
+
const payload = `id: ${change.revision}\nevent: change\ndata: ${JSON.stringify(change)}\n\n`;
|
|
281
|
+
for (const client of eventClients) client.write(payload);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
product.on("change", broadcast);
|
|
285
|
+
|
|
286
|
+
const server = createServer(async (request, response) => {
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(request.url ?? "/", `http://${formatHost(host)}:${port}`);
|
|
289
|
+
const { pathname } = url;
|
|
290
|
+
const method = request.method ?? "GET";
|
|
291
|
+
if (!["GET", "HEAD"].includes(method)) {
|
|
292
|
+
validateMutationOrigin(request, server.address(), host);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (method === "OPTIONS") {
|
|
296
|
+
response.writeHead(204, {
|
|
297
|
+
"Access-Control-Allow-Origin": request.headers.origin ?? "null",
|
|
298
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
299
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
300
|
+
"Access-Control-Max-Age": "600",
|
|
301
|
+
});
|
|
302
|
+
response.end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (method === "GET" && pathname === "/api/health") {
|
|
307
|
+
sendJson(response, 200, {
|
|
308
|
+
ok: true,
|
|
309
|
+
service: "notes",
|
|
310
|
+
protocolVersion: 1,
|
|
311
|
+
instanceId,
|
|
312
|
+
revision: product.revision,
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (method === "GET" && pathname === "/api/state") {
|
|
318
|
+
const surface = url.searchParams.get("surface") ?? "all";
|
|
319
|
+
sendJson(response, 200, await product.getState({
|
|
320
|
+
surface,
|
|
321
|
+
kind: url.searchParams.get("kind"),
|
|
322
|
+
id: url.searchParams.get("id"),
|
|
323
|
+
}));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (method === "GET" && pathname === "/api/events") {
|
|
328
|
+
response.writeHead(200, {
|
|
329
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
330
|
+
"Cache-Control": "no-cache, no-transform",
|
|
331
|
+
Connection: "keep-alive",
|
|
332
|
+
"X-Accel-Buffering": "no",
|
|
333
|
+
});
|
|
334
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ revision: product.revision })}\n\n`);
|
|
335
|
+
eventClients.add(response);
|
|
336
|
+
request.on("close", () => eventClients.delete(response));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (method === "POST" && pathname === "/api/todos") {
|
|
341
|
+
sendJson(response, 201, product.createTodo(await readJson(request)));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (method === "POST" && pathname === "/api/todos/reorder") {
|
|
345
|
+
const body = await readJson(request);
|
|
346
|
+
sendJson(response, 200, product.reorderTodos(body.ids));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (method === "DELETE" && pathname === "/api/todos/completed") {
|
|
350
|
+
sendJson(response, 200, product.clearCompletedTodos());
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const todoId = idFrom(pathname, "/api/todos/");
|
|
354
|
+
if (todoId && method === "PATCH") {
|
|
355
|
+
sendJson(response, 200, product.updateTodo(todoId, await readJson(request)));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (todoId && method === "DELETE") {
|
|
359
|
+
product.deleteTodo(todoId);
|
|
360
|
+
sendEmpty(response);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (method === "POST" && pathname === "/api/prompts") {
|
|
365
|
+
sendJson(response, 201, product.createPrompt(await readJson(request)));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const promptUseId = idFrom(pathname, "/api/prompts/", "/use");
|
|
369
|
+
if (promptUseId && method === "POST") {
|
|
370
|
+
sendJson(response, 200, product.markPromptUsed(promptUseId, await readJson(request)));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const promptId = idFrom(pathname, "/api/prompts/");
|
|
374
|
+
if (promptId && method === "PATCH") {
|
|
375
|
+
sendJson(response, 200, product.updatePrompt(promptId, await readJson(request)));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (promptId && method === "DELETE") {
|
|
379
|
+
product.deletePrompt(promptId);
|
|
380
|
+
sendEmpty(response);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (method === "POST" && pathname === "/api/files") {
|
|
385
|
+
sendJson(response, 200, await product.addFiles(await readJson(request)));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (method === "POST" && pathname === "/api/files/pick") {
|
|
389
|
+
const picked = await filePicker({ locale: requestLocale(request) });
|
|
390
|
+
if (picked.cancelled) {
|
|
391
|
+
sendJson(response, 200, { cancelled: true, added: [], existing: [], rejected: [], revision: product.revision });
|
|
392
|
+
} else {
|
|
393
|
+
sendJson(response, 200, { cancelled: false, ...await product.addFiles({ paths: picked.paths }) });
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const fileUseId = idFrom(pathname, "/api/files/", "/use");
|
|
398
|
+
if (fileUseId && method === "POST") {
|
|
399
|
+
sendJson(response, 200, await product.markFileUsed(fileUseId, await readJson(request)));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const fileRevealId = idFrom(pathname, "/api/files/", "/reveal");
|
|
403
|
+
if (fileRevealId && method === "POST") {
|
|
404
|
+
const file = product.getFile(fileRevealId);
|
|
405
|
+
await fileRevealer(file.path);
|
|
406
|
+
sendJson(response, 200, { revealed: true });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const filePreviewId = idFrom(pathname, "/api/files/", "/preview");
|
|
410
|
+
if (filePreviewId && (method === "GET" || method === "HEAD")) {
|
|
411
|
+
const file = product.getFile(filePreviewId);
|
|
412
|
+
if (!file.mimeType.startsWith("image/")) {
|
|
413
|
+
throw new ProductError("Only pinned image files have previews", {
|
|
414
|
+
code: "PREVIEW_UNAVAILABLE",
|
|
415
|
+
status: 415,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
let info;
|
|
419
|
+
try {
|
|
420
|
+
info = await stat(file.path);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
|
|
423
|
+
product.removeMissingFile(file.id);
|
|
424
|
+
throw new ProductError("The source image no longer exists", {
|
|
425
|
+
code: "FILE_MISSING",
|
|
426
|
+
status: 410,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
if (!info.isFile()) {
|
|
432
|
+
product.removeMissingFile(file.id);
|
|
433
|
+
throw new ProductError("The source image no longer exists", {
|
|
434
|
+
code: "FILE_MISSING",
|
|
435
|
+
status: 410,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (info.size > MAX_PREVIEW_BYTES) {
|
|
439
|
+
throw new ProductError("The source image is too large to preview", {
|
|
440
|
+
code: "PREVIEW_TOO_LARGE",
|
|
441
|
+
status: 413,
|
|
442
|
+
details: { maxBytes: MAX_PREVIEW_BYTES },
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const etag = `W/"${info.size.toString(16)}-${Math.trunc(info.mtimeMs).toString(16)}"`;
|
|
446
|
+
if (request.headers["if-none-match"] === etag) {
|
|
447
|
+
response.writeHead(304, {
|
|
448
|
+
ETag: etag,
|
|
449
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
450
|
+
});
|
|
451
|
+
response.end();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
response.writeHead(200, {
|
|
455
|
+
"Content-Type": file.mimeType,
|
|
456
|
+
"Content-Length": info.size,
|
|
457
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
458
|
+
ETag: etag,
|
|
459
|
+
"Last-Modified": info.mtime.toUTCString(),
|
|
460
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
461
|
+
"X-Content-Type-Options": "nosniff",
|
|
462
|
+
"Referrer-Policy": "no-referrer",
|
|
463
|
+
});
|
|
464
|
+
if (method === "HEAD") response.end();
|
|
465
|
+
else await pipeline(createReadStream(file.path), response);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const fileId = idFrom(pathname, "/api/files/");
|
|
469
|
+
if (fileId && method === "DELETE") {
|
|
470
|
+
product.deleteFile(fileId);
|
|
471
|
+
sendEmpty(response);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const staticEntry = STATIC_FILES.get(pathname);
|
|
476
|
+
if (staticEntry && (method === "GET" || method === "HEAD")) {
|
|
477
|
+
const source = await readFile(staticEntry.absoluteFile ?? path.join(webRoot, staticEntry.file));
|
|
478
|
+
const body = staticEntry.opticalStrokeWidth
|
|
479
|
+
? Buffer.from(source.toString("utf8").replace(
|
|
480
|
+
/stroke-width="[^"]+"/,
|
|
481
|
+
`stroke-width="${staticEntry.opticalStrokeWidth}"`,
|
|
482
|
+
))
|
|
483
|
+
: source;
|
|
484
|
+
response.writeHead(200, {
|
|
485
|
+
"Content-Type": staticEntry.type,
|
|
486
|
+
"Content-Length": body.length,
|
|
487
|
+
"Cache-Control": "no-cache",
|
|
488
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
|
|
489
|
+
"X-Content-Type-Options": "nosniff",
|
|
490
|
+
"Referrer-Policy": "no-referrer",
|
|
491
|
+
});
|
|
492
|
+
if (method === "HEAD") response.end();
|
|
493
|
+
else response.end(body);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
sendJson(response, 404, { error: { code: "NOT_FOUND", message: "Route was not found" } });
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const status = error instanceof ProductError ? error.status : 500;
|
|
500
|
+
const code = error instanceof ProductError ? error.code : "INTERNAL_ERROR";
|
|
501
|
+
const message = error instanceof ProductError ? error.message : "CodexNotes could not complete the request";
|
|
502
|
+
if (status >= 500) console.error(error);
|
|
503
|
+
if (!response.headersSent) {
|
|
504
|
+
sendJson(response, status, { error: { code, message, details: error.details ?? null } });
|
|
505
|
+
} else {
|
|
506
|
+
response.destroy();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
server,
|
|
513
|
+
get origin() {
|
|
514
|
+
const address = server.address();
|
|
515
|
+
if (!address || typeof address === "string") return null;
|
|
516
|
+
return `http://${formatHost(host)}:${address.port}`;
|
|
517
|
+
},
|
|
518
|
+
async listen() {
|
|
519
|
+
await new Promise((resolve, reject) => {
|
|
520
|
+
server.once("error", reject);
|
|
521
|
+
server.listen(port, host, () => {
|
|
522
|
+
server.off("error", reject);
|
|
523
|
+
resolve();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
heartbeat = setInterval(() => {
|
|
527
|
+
for (const client of eventClients) client.write(": heartbeat\n\n");
|
|
528
|
+
}, 20_000);
|
|
529
|
+
heartbeat.unref();
|
|
530
|
+
return this;
|
|
531
|
+
},
|
|
532
|
+
async close() {
|
|
533
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
534
|
+
for (const client of eventClients) client.end();
|
|
535
|
+
eventClients.clear();
|
|
536
|
+
product.off("change", broadcast);
|
|
537
|
+
if (!server.listening) return;
|
|
538
|
+
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { createCodexNotesServer } from "./http-server.mjs";
|
|
7
|
+
import { CodexNotesProduct } from "./product.mjs";
|
|
8
|
+
|
|
9
|
+
const host = process.env.CODEX_NOTES_HOST ?? "127.0.0.1";
|
|
10
|
+
const port = Number(process.env.CODEX_NOTES_PORT ?? "47832");
|
|
11
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
12
|
+
throw new Error("CODEX_NOTES_PORT must be an integer between 0 and 65535");
|
|
13
|
+
}
|
|
14
|
+
const dataDirectory = process.env.CODEX_NOTES_DATA_DIR
|
|
15
|
+
? path.resolve(process.env.CODEX_NOTES_DATA_DIR)
|
|
16
|
+
: path.join(os.homedir(), "Library", "Application Support", "CodexNotes");
|
|
17
|
+
const databasePath = process.env.CODEX_NOTES_DATABASE
|
|
18
|
+
? path.resolve(process.env.CODEX_NOTES_DATABASE)
|
|
19
|
+
: path.join(dataDirectory, "codex-notes.sqlite");
|
|
20
|
+
|
|
21
|
+
const product = await new CodexNotesProduct({ databasePath }).open();
|
|
22
|
+
let app;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
app = createCodexNotesServer({ product, host, port });
|
|
26
|
+
await app.listen();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
product.close();
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
process.stdout.write(`${JSON.stringify({
|
|
33
|
+
event: "codex-notes:ready",
|
|
34
|
+
origin: app.origin,
|
|
35
|
+
databasePath,
|
|
36
|
+
})}\n`);
|
|
37
|
+
|
|
38
|
+
let stopping = false;
|
|
39
|
+
async function stop(signal) {
|
|
40
|
+
if (stopping) return;
|
|
41
|
+
stopping = true;
|
|
42
|
+
await app.close();
|
|
43
|
+
product.close();
|
|
44
|
+
process.stdout.write(`${JSON.stringify({ event: "codex-notes:stopped", signal })}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
48
|
+
process.on(signal, () => {
|
|
49
|
+
stop(signal).then(() => process.exit(0), (error) => {
|
|
50
|
+
console.error(error);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|