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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/apps/dashboard/app.js +387 -0
  4. package/apps/dashboard/favicon.svg +18 -0
  5. package/apps/dashboard/index.html +70 -0
  6. package/apps/dashboard/styles.css +297 -0
  7. package/features/images/README.md +106 -0
  8. package/features/images/codex-feature.json +24 -0
  9. package/features/images/src/http-server.mjs +302 -0
  10. package/features/images/src/image-library.mjs +582 -0
  11. package/features/images/src/main.mjs +52 -0
  12. package/features/images/src/prompt-index.mjs +182 -0
  13. package/features/images/src/save-as.mjs +40 -0
  14. package/features/images/src/thread-metadata.mjs +93 -0
  15. package/features/images/web/app.js +1045 -0
  16. package/features/images/web/index.html +162 -0
  17. package/features/images/web/styles.css +994 -0
  18. package/features/model-slider/README.md +30 -0
  19. package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
  20. package/features/model-slider/assets/providers/anthropic.svg +1 -0
  21. package/features/model-slider/assets/providers/deepseek.svg +1 -0
  22. package/features/model-slider/assets/providers/gemini.svg +1 -0
  23. package/features/model-slider/assets/providers/grok.svg +1 -0
  24. package/features/model-slider/assets/providers/hunyuan.svg +1 -0
  25. package/features/model-slider/assets/providers/kimi.svg +1 -0
  26. package/features/model-slider/assets/providers/minimax.svg +1 -0
  27. package/features/model-slider/assets/providers/openai.svg +1 -0
  28. package/features/model-slider/assets/providers/qwen.svg +1 -0
  29. package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
  30. package/features/model-slider/assets/providers/zhipu.svg +1 -0
  31. package/features/model-slider/assets/ui/sparkles.svg +18 -0
  32. package/features/model-slider/assets/ui/star-off.svg +17 -0
  33. package/features/model-slider/assets/ui/star.svg +15 -0
  34. package/features/model-slider/codex-feature.json +56 -0
  35. package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
  36. package/features/model-slider/vendor/lucide-LICENSE +35 -0
  37. package/features/notes/README.md +106 -0
  38. package/features/notes/assets/icons/circle.svg +15 -0
  39. package/features/notes/assets/icons/ellipsis.svg +17 -0
  40. package/features/notes/assets/icons/external-link.svg +17 -0
  41. package/features/notes/assets/icons/list-todo.svg +19 -0
  42. package/features/notes/assets/icons/message-square-quote.svg +17 -0
  43. package/features/notes/assets/icons/paperclip.svg +15 -0
  44. package/features/notes/assets/icons/star-off.svg +17 -0
  45. package/features/notes/assets/icons/star.svg +15 -0
  46. package/features/notes/assets/icons/sticky-note.svg +16 -0
  47. package/features/notes/assets/icons/trash-2.svg +19 -0
  48. package/features/notes/codex-feature.json +90 -0
  49. package/features/notes/src/http-server.mjs +541 -0
  50. package/features/notes/src/main.mjs +54 -0
  51. package/features/notes/src/product.mjs +771 -0
  52. package/features/notes/web/app.js +1495 -0
  53. package/features/notes/web/codex-artifact-document.svg +1 -0
  54. package/features/notes/web/codex-attachment.svg +1 -0
  55. package/features/notes/web/codex-build.svg +1 -0
  56. package/features/notes/web/codex-code.svg +1 -0
  57. package/features/notes/web/codex-cplusplus.svg +1 -0
  58. package/features/notes/web/codex-css.svg +1 -0
  59. package/features/notes/web/codex-document.svg +1 -0
  60. package/features/notes/web/codex-file.svg +1 -0
  61. package/features/notes/web/codex-folder.svg +1 -0
  62. package/features/notes/web/codex-hashes.svg +1 -0
  63. package/features/notes/web/codex-html.svg +1 -0
  64. package/features/notes/web/codex-image.svg +1 -0
  65. package/features/notes/web/codex-java.svg +1 -0
  66. package/features/notes/web/codex-javascript.svg +1 -0
  67. package/features/notes/web/codex-json.svg +1 -0
  68. package/features/notes/web/codex-notebook.svg +1 -0
  69. package/features/notes/web/codex-pdf.svg +1 -0
  70. package/features/notes/web/codex-pencil.svg +3 -0
  71. package/features/notes/web/codex-php.svg +1 -0
  72. package/features/notes/web/codex-plus.svg +3 -0
  73. package/features/notes/web/codex-presentation.svg +1 -0
  74. package/features/notes/web/codex-python.svg +1 -0
  75. package/features/notes/web/codex-react.svg +1 -0
  76. package/features/notes/web/codex-rust.svg +1 -0
  77. package/features/notes/web/codex-shell.svg +1 -0
  78. package/features/notes/web/codex-skill.svg +1 -0
  79. package/features/notes/web/codex-spreadsheet.svg +1 -0
  80. package/features/notes/web/codex-task.svg +3 -0
  81. package/features/notes/web/codex-terminal.svg +1 -0
  82. package/features/notes/web/codex-toml.svg +1 -0
  83. package/features/notes/web/codex-typescript.svg +1 -0
  84. package/features/notes/web/codex-view-all.svg +3 -0
  85. package/features/notes/web/index.html +15 -0
  86. package/features/notes/web/styles.css +1155 -0
  87. package/package.json +67 -0
  88. package/packages/cli/bin/mcx.mjs +7 -0
  89. package/packages/cli/src/browser-exchange.mjs +31 -0
  90. package/packages/cli/src/commands.mjs +389 -0
  91. package/packages/cli/src/config.mjs +101 -0
  92. package/packages/cli/src/control-server.mjs +371 -0
  93. package/packages/cli/src/npm-adapter.mjs +40 -0
  94. package/packages/cli/src/paths.mjs +33 -0
  95. package/packages/cli/src/platform.mjs +368 -0
  96. package/packages/cli/src/runtime-manager.mjs +338 -0
  97. package/packages/runtime-host/README.md +171 -0
  98. package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
  99. package/packages/runtime-host/src/feature-registry.mjs +827 -0
  100. package/packages/runtime-host/src/main.mjs +98 -0
  101. package/packages/runtime-host/src/pid-file.mjs +23 -0
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "minecodex",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mcx": "packages/cli/bin/mcx.mjs"
9
+ },
10
+ "workspaces": [
11
+ "packages/*",
12
+ "features/*",
13
+ "apps/*"
14
+ ],
15
+ "files": [
16
+ "LICENSE",
17
+ "packages/cli/bin",
18
+ "packages/cli/src",
19
+ "packages/runtime-host/src",
20
+ "features/model-slider/assets",
21
+ "features/model-slider/codex-feature.json",
22
+ "features/model-slider/THIRD_PARTY_NOTICES.md",
23
+ "features/model-slider/vendor",
24
+ "features/notes/src",
25
+ "features/notes/web",
26
+ "features/notes/assets",
27
+ "features/notes/codex-feature.json",
28
+ "features/images/src",
29
+ "features/images/web",
30
+ "features/images/codex-feature.json",
31
+ "apps/dashboard/app.js",
32
+ "apps/dashboard/favicon.svg",
33
+ "apps/dashboard/index.html",
34
+ "apps/dashboard/styles.css",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22.5.0"
39
+ },
40
+ "scripts": {
41
+ "start": "node packages/cli/bin/mcx.mjs serve",
42
+ "test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs",
43
+ "test:cli": "node --test packages/cli/test/*.test.mjs",
44
+ "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
45
+ "check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
46
+ "pack:check": "npm pack --dry-run",
47
+ "pack:audit": "node scripts/audit-package.mjs",
48
+ "prepublishOnly": "npm test && npm run check && npm run pack:audit"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/Vontean/MineCodex.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/Vontean/MineCodex/issues"
56
+ },
57
+ "homepage": "https://github.com/Vontean/MineCodex#readme",
58
+ "keywords": [
59
+ "codex",
60
+ "minecodex",
61
+ "macos",
62
+ "plugins"
63
+ ],
64
+ "os": [
65
+ "darwin"
66
+ ]
67
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/commands.mjs";
3
+
4
+ runCli(process.argv.slice(2)).catch((error) => {
5
+ console.error(`MineCodex: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,31 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export const BROWSER_EXCHANGE_TTL_MS = 30_000;
4
+
5
+ export class BrowserExchangeStore {
6
+ constructor({ ttlMs = BROWSER_EXCHANGE_TTL_MS, now = () => Date.now(), createValue = () => randomBytes(32).toString("base64url") } = {}) {
7
+ this.ttlMs = ttlMs;
8
+ this.now = now;
9
+ this.createValue = createValue;
10
+ this.values = new Map();
11
+ }
12
+
13
+ issue(controlToken) {
14
+ const now = this.now();
15
+ for (const [value, entry] of this.values) {
16
+ if (entry.expiresAt <= now) this.values.delete(value);
17
+ }
18
+ const value = this.createValue();
19
+ this.values.set(value, { controlToken, expiresAt: now + this.ttlMs });
20
+ return value;
21
+ }
22
+
23
+ consume(value) {
24
+ const entry = this.values.get(value);
25
+ this.values.delete(value);
26
+ if (!entry || entry.expiresAt <= this.now()) {
27
+ throw Object.assign(new Error("Browser exchange is invalid or expired."), { code: "INVALID_EXCHANGE" });
28
+ }
29
+ return entry.controlToken;
30
+ }
31
+ }
@@ -0,0 +1,389 @@
1
+ import { access, readFile, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { assertNodeSupported, MacPlatformAdapter } from "./platform.mjs";
4
+ import { ensureConfig, ensureControlToken, readConfig } from "./config.mjs";
5
+ import { createControlServer } from "./control-server.mjs";
6
+ import { createNpmAdapter } from "./npm-adapter.mjs";
7
+ import { resolvePaths } from "./paths.mjs";
8
+ import { RuntimeManager, writeServicePid } from "./runtime-manager.mjs";
9
+
10
+ const packageMetadata = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8"));
11
+ const DEFAULT_CONTROL_PORT = 47830;
12
+
13
+ function controlOrigin(port = Number(process.env.MINECODEX_CONTROL_PORT ?? DEFAULT_CONTROL_PORT)) {
14
+ return `http://127.0.0.1:${port}`;
15
+ }
16
+
17
+ async function exists(filePath) {
18
+ try {
19
+ await access(filePath);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ async function waitForService(port, { fetchImpl = fetch, timeoutMs = 20_000 } = {}) {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ try {
30
+ const response = await fetchImpl(`${controlOrigin(port)}/healthz`, { signal: AbortSignal.timeout(750) });
31
+ if (response.ok) {
32
+ const identity = await response.json().catch(() => null);
33
+ if (identity?.service === "minecodex" && identity?.ok === true) return true;
34
+ }
35
+ } catch {}
36
+ await new Promise((resolve) => setTimeout(resolve, 250));
37
+ }
38
+ return false;
39
+ }
40
+
41
+ async function waitForManagedReady(paths, { fetchImpl = fetch, timeoutMs = 30_000 } = {}) {
42
+ const deadline = Date.now() + timeoutMs;
43
+ while (Date.now() < deadline) {
44
+ try {
45
+ const response = await authorizedFetch(paths, "/api/status", {}, { fetchImpl });
46
+ if (response.ok) {
47
+ const status = await response.json();
48
+ if (status.runtime?.running && !status.pendingRestart) return status;
49
+ }
50
+ } catch {}
51
+ await new Promise((resolve) => setTimeout(resolve, 250));
52
+ }
53
+ return null;
54
+ }
55
+
56
+ async function authorizedFetch(paths, pathname, options = {}, { fetchImpl = fetch } = {}) {
57
+ const token = await ensureControlToken(paths.tokenPath);
58
+ const origin = controlOrigin();
59
+ return fetchImpl(`${origin}${pathname}`, {
60
+ ...options,
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ Origin: origin,
64
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
65
+ ...options.headers,
66
+ },
67
+ });
68
+ }
69
+
70
+ function printHelp(output = console.log) {
71
+ output(`MineCodex ${packageMetadata.version}
72
+
73
+ Usage: mcx <command>
74
+
75
+ Commands:
76
+ mcx install Install or repair MineCodex on macOS
77
+ mcx uninstall [--purge] Remove integration; preserve data unless --purge is used
78
+ mcx update Update to the latest stable npm release
79
+ mcx open Open the MineCodex plugin control panel
80
+ mcx status [--json] Show installation and plugin status
81
+ mcx restart Restart Codex and apply saved plugin switches
82
+ mcx --version Print the installed version
83
+ mcx --help Show this help`);
84
+ }
85
+
86
+ function serviceSnapshot(platform, paths) {
87
+ return platform.snapshotServiceState?.(paths) ?? null;
88
+ }
89
+
90
+ async function restoreService(platform, snapshot, { cliPath, paths }) {
91
+ if (!snapshot || !platform.restoreServiceState) return null;
92
+ return platform.restoreServiceState(snapshot, { cliPath, paths });
93
+ }
94
+
95
+ async function nativeSnapshot(platform) {
96
+ if (platform.snapshotNativeCodexState) return platform.snapshotNativeCodexState();
97
+ return { count: 0 };
98
+ }
99
+
100
+ async function restoreNative(platform, snapshot) {
101
+ if (!snapshot?.count) return;
102
+ if (platform.restoreNativeCodexState) {
103
+ await platform.restoreNativeCodexState(snapshot);
104
+ return;
105
+ }
106
+ await platform.openNativeCodex?.();
107
+ }
108
+
109
+ function appendRollbackError(error, rollbackError) {
110
+ if (!rollbackError) return error;
111
+ return new Error(`${error.message} Automatic rollback also failed: ${rollbackError.message}`, { cause: error });
112
+ }
113
+
114
+ async function installCommand({ paths, platform, cliPath, output, serviceProbe, fetchImpl, nodeVersion = process.versions.node }) {
115
+ assertNodeSupported(nodeVersion);
116
+ platform.assertInstallSupported();
117
+ await platform.assertCodexInstalled();
118
+ const previousService = await serviceSnapshot(platform, paths);
119
+ const previousNative = await nativeSnapshot(platform);
120
+ await ensureConfig(paths.configPath);
121
+ await ensureControlToken(paths.tokenPath);
122
+ let nativeCount = 0;
123
+ try {
124
+ nativeCount = await platform.terminateNativeCodex();
125
+ if (nativeCount) output(`Restarting ${nativeCount} running Codex instance${nativeCount === 1 ? "" : "s"} with MineCodex enabled…`);
126
+ await platform.installService({ cliPath, paths, start: true });
127
+ const waitForServiceImpl = serviceProbe?.waitForService ?? waitForService;
128
+ const waitForManagedReadyImpl = serviceProbe?.waitForManagedReady ?? waitForManagedReady;
129
+ if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl }))
130
+ || !(await waitForManagedReadyImpl(paths, { fetchImpl }))) {
131
+ throw new Error(`MineCodex service did not become ready. Check ${paths.serviceErrorLogPath}`);
132
+ }
133
+ } catch (error) {
134
+ let rollbackError = null;
135
+ try {
136
+ await restoreService(platform, previousService, { cliPath, paths });
137
+ } catch (failure) {
138
+ rollbackError = failure;
139
+ }
140
+ try {
141
+ await restoreNative(platform, previousNative?.count ? previousNative : { count: nativeCount });
142
+ } catch (failure) {
143
+ rollbackError = rollbackError ?? failure;
144
+ }
145
+ throw appendRollbackError(error, rollbackError);
146
+ }
147
+ output("MineCodex installed. Model Slider, Notes, and Images are enabled by default.");
148
+ output("Run `mcx open` to manage plugins.");
149
+ }
150
+
151
+ async function openCommand({ paths, platform, serviceProbe, fetchImpl }) {
152
+ const waitForServiceImpl = serviceProbe?.waitForService ?? waitForService;
153
+ if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl, timeoutMs: 750 }))) {
154
+ if (!(await exists(paths.launchAgentPath))) throw new Error("MineCodex is not installed. Run `mcx install` first.");
155
+ await platform.startService(paths);
156
+ if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl }))) throw new Error("MineCodex service could not be started.");
157
+ }
158
+ const exchangeResponse = await authorizedFetch(paths, "/api/auth/exchange", {
159
+ method: "POST",
160
+ body: "{}",
161
+ }, { fetchImpl });
162
+ if (!exchangeResponse.ok) throw new Error(`MineCodex browser exchange failed: HTTP ${exchangeResponse.status}`);
163
+ const { exchange } = await exchangeResponse.json();
164
+ if (!exchange) throw new Error("MineCodex browser exchange did not return a short-lived value.");
165
+ await platform.openBrowser(`${controlOrigin()}/auth?exchange=${encodeURIComponent(exchange)}`);
166
+ }
167
+
168
+ function featureStatusText(feature) {
169
+ const state = feature.state ?? (!feature.enabled ? "stopped" : feature.healthy ? "healthy" : feature.applied ? "launching" : "restart-required");
170
+ const failure = feature.failure?.message ? `: ${feature.failure.message}` : "";
171
+ const copy = {
172
+ saved: "saved; restart required",
173
+ configured: "configured; launching",
174
+ launching: "launching",
175
+ injected: "injected into Codex",
176
+ healthy: "healthy in Codex",
177
+ failed: "failed; run `mcx restart`",
178
+ stopped: "stopped",
179
+ "restart-required": "saved; run `mcx restart`",
180
+ };
181
+ return `${copy[state] ?? state}${failure}`;
182
+ }
183
+
184
+ async function statusCommand({ paths, jsonOutput, output, fetchImpl }) {
185
+ let status;
186
+ try {
187
+ const response = await authorizedFetch(paths, "/api/status", {}, { fetchImpl });
188
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
189
+ status = { installed: await exists(paths.launchAgentPath), service: { running: true }, ...await response.json() };
190
+ } catch {
191
+ const config = await readConfig(paths.configPath).catch(() => null);
192
+ status = {
193
+ installed: await exists(paths.launchAgentPath),
194
+ version: packageMetadata.version,
195
+ service: { running: false },
196
+ runtime: { running: false },
197
+ pendingRestart: false,
198
+ features: Object.entries(config?.features ?? {}).map(([id, enabled]) => ({
199
+ id,
200
+ enabled,
201
+ applied: false,
202
+ injected: false,
203
+ healthy: false,
204
+ state: "stopped",
205
+ failure: null,
206
+ })),
207
+ };
208
+ }
209
+ if (jsonOutput) {
210
+ output(JSON.stringify(status, null, 2));
211
+ return status;
212
+ }
213
+ output(`MineCodex ${status.version}`);
214
+ output(`Installed: ${status.installed ? "yes" : "no"}`);
215
+ output(`Service: ${status.service.running ? "running" : "stopped"}`);
216
+ output(`RuntimeHost: ${status.runtime?.running ? "running" : "stopped"}`);
217
+ for (const feature of status.features ?? []) output(`- ${feature.id}: ${featureStatusText(feature)}`);
218
+ if (status.pendingRestart) output("Saved plugin changes require `mcx restart`; the current Codex session is unchanged.");
219
+ return status;
220
+ }
221
+
222
+ async function restartCommand({ paths, output, fetchImpl }) {
223
+ const response = await authorizedFetch(paths, "/api/restart", { method: "POST", body: "{}" }, { fetchImpl });
224
+ if (!response.ok) throw new Error(`MineCodex restart failed: HTTP ${response.status}`);
225
+ output("MineCodex restarted and saved plugin switches are now applied.");
226
+ }
227
+
228
+ async function uninstallCommand({ paths, platform, purge, output, cliPath }) {
229
+ platform.assertInstallSupported();
230
+ const previousService = await serviceSnapshot(platform, paths);
231
+ let wasManaged = false;
232
+ try {
233
+ await platform.removeService(paths);
234
+ wasManaged = await platform.terminateOwnedCodex(paths);
235
+ await platform.assertServiceStopped?.();
236
+ await platform.assertOwnedCodexStopped?.(paths);
237
+ if (purge) {
238
+ const targets = [paths.supportDir, paths.notesDataDir, paths.imagesDataDir];
239
+ for (const target of targets) {
240
+ if (!path.isAbsolute(target) || target === paths.homeDir || target.length <= paths.homeDir.length + 2) {
241
+ throw new Error(`Refusing unsafe purge target: ${target}`);
242
+ }
243
+ }
244
+ for (const target of targets) await rm(target, { recursive: true, force: true });
245
+ output("MineCodex integration and MineCodex-owned data were removed.");
246
+ } else {
247
+ output("MineCodex integration was removed. Plugin data was preserved.");
248
+ }
249
+ } catch (error) {
250
+ let rollbackError = null;
251
+ try {
252
+ await restoreService(platform, previousService, { cliPath, paths });
253
+ } catch (failure) {
254
+ rollbackError = failure;
255
+ }
256
+ throw appendRollbackError(error, rollbackError);
257
+ }
258
+ if (wasManaged) await platform.openNativeCodex();
259
+ output("Codex generated images and files referenced by Notes were not modified.");
260
+ }
261
+
262
+ async function updateCommand({ paths, platform, cliPath, output, npm = createNpmAdapter(), serviceProbe, fetchImpl }) {
263
+ platform.assertInstallSupported();
264
+ const latest = await npm.viewLatestVersion();
265
+ if (!latest) throw new Error("Could not resolve the latest MineCodex version.");
266
+ if (latest === packageMetadata.version) {
267
+ output(`MineCodex ${packageMetadata.version} is already up to date.`);
268
+ return;
269
+ }
270
+ const wasInstalled = await exists(paths.launchAgentPath);
271
+ const previousService = wasInstalled
272
+ ? (await serviceSnapshot(platform, paths) ?? { running: true, plist: { exists: true } })
273
+ : null;
274
+ const rollback = await npm.createRollbackArchive(paths.packageRoot);
275
+ const rollbackPath = typeof rollback === "string" ? rollback : rollback.archivePath;
276
+ if (!rollbackPath) throw new Error("Could not create a rollback archive for the installed MineCodex version.");
277
+ try {
278
+ if (wasInstalled && previousService?.running) {
279
+ await platform.stopService({ ignoreErrors: false });
280
+ await platform.assertServiceStopped?.();
281
+ }
282
+ await npm.installGlobal("minecodex@latest");
283
+ if (wasInstalled) {
284
+ await platform.installService({ cliPath, paths, start: previousService.running });
285
+ if (previousService.running) {
286
+ const waitForServiceImpl = serviceProbe?.waitForService ?? waitForService;
287
+ const waitForManagedReadyImpl = serviceProbe?.waitForManagedReady ?? waitForManagedReady;
288
+ if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl }))
289
+ || !(await waitForManagedReadyImpl(paths, { fetchImpl }))) {
290
+ throw new Error("MineCodex service did not become ready after update.");
291
+ }
292
+ } else {
293
+ await platform.assertServiceStopped?.();
294
+ }
295
+ }
296
+ output(`MineCodex updated from ${packageMetadata.version} to ${latest}.`);
297
+ } catch (error) {
298
+ let rollbackError = null;
299
+ try {
300
+ await npm.installGlobal(rollbackPath);
301
+ await restoreService(platform, previousService, { cliPath, paths });
302
+ if (wasInstalled && previousService?.running) {
303
+ const waitForServiceImpl = serviceProbe?.waitForService ?? waitForService;
304
+ const waitForManagedReadyImpl = serviceProbe?.waitForManagedReady ?? waitForManagedReady;
305
+ if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl }))
306
+ || !(await waitForManagedReadyImpl(paths, { fetchImpl }))) {
307
+ throw new Error("Rolled-back MineCodex service did not become ready.");
308
+ }
309
+ } else if (wasInstalled && !previousService?.running) {
310
+ await platform.assertServiceStopped?.();
311
+ }
312
+ } catch (failure) {
313
+ rollbackError = failure;
314
+ }
315
+ const restored = rollbackError ? "" : " The previous MineCodex version was restored.";
316
+ throw appendRollbackError(new Error(
317
+ `MineCodex update failed; settings and data were preserved. ${error.message}${restored}`,
318
+ { cause: error },
319
+ ), rollbackError);
320
+ } finally {
321
+ if (npm.cleanup) await npm.cleanup(rollback).catch(() => {});
322
+ }
323
+ }
324
+
325
+ async function serveCommand({ paths, platform, logger }) {
326
+ await ensureConfig(paths.configPath);
327
+ const token = await ensureControlToken(paths.tokenPath);
328
+ const runtimeManager = new RuntimeManager({ paths, platform, logger });
329
+ let control;
330
+ let removePid = async () => {};
331
+ try {
332
+ control = await createControlServer({
333
+ paths,
334
+ token,
335
+ runtimeManager,
336
+ port: Number(process.env.MINECODEX_CONTROL_PORT ?? DEFAULT_CONTROL_PORT),
337
+ logger,
338
+ });
339
+ removePid = await writeServicePid(paths.pidPath);
340
+ await runtimeManager.start();
341
+ } catch (error) {
342
+ await runtimeManager.stop().catch(() => {});
343
+ await control?.close().catch(() => {});
344
+ await removePid();
345
+ throw error;
346
+ }
347
+ logger.log(`MineCodex control panel: ${control.url}`);
348
+ let shuttingDown = false;
349
+ const shutdown = async () => {
350
+ if (shuttingDown) return;
351
+ shuttingDown = true;
352
+ await control.close().catch(() => {});
353
+ await runtimeManager.stop();
354
+ await removePid();
355
+ };
356
+ for (const signal of ["SIGINT", "SIGTERM"]) {
357
+ process.once(signal, async () => {
358
+ await shutdown();
359
+ process.exit(0);
360
+ });
361
+ }
362
+ return new Promise(() => {});
363
+ }
364
+
365
+ export async function runCli(argv, {
366
+ paths = resolvePaths(),
367
+ platform = new MacPlatformAdapter(),
368
+ cliPath = process.argv[1],
369
+ output = console.log,
370
+ logger = console,
371
+ npm = createNpmAdapter(),
372
+ serviceProbe,
373
+ fetchImpl = fetch,
374
+ nodeVersion = process.versions.node,
375
+ } = {}) {
376
+ const [command = "--help", ...args] = argv;
377
+ if (["--help", "-h", "help"].includes(command)) return printHelp(output);
378
+ if (["--version", "-v"].includes(command)) return output(packageMetadata.version);
379
+ if (command === "install") return installCommand({ paths, platform, cliPath, output, serviceProbe, fetchImpl, nodeVersion });
380
+ if (command === "open" || command === "gui") return openCommand({ paths, platform, serviceProbe, fetchImpl });
381
+ if (command === "status") return statusCommand({ paths, jsonOutput: args.includes("--json"), output, fetchImpl });
382
+ if (command === "restart") return restartCommand({ paths, output, fetchImpl });
383
+ if (command === "uninstall" || command === "remove") {
384
+ return uninstallCommand({ paths, platform, purge: args.includes("--purge"), output, cliPath });
385
+ }
386
+ if (command === "update") return updateCommand({ paths, platform, cliPath, output, npm, serviceProbe, fetchImpl });
387
+ if (command === "serve") return serveCommand({ paths, platform, logger });
388
+ throw new Error(`Unknown MineCodex command: ${command}. Run \`mcx --help\`.`);
389
+ }
@@ -0,0 +1,101 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images"]);
6
+ export const DEFAULT_CONFIG = Object.freeze({
7
+ schemaVersion: 1,
8
+ features: Object.freeze({
9
+ "model-slider": true,
10
+ notes: true,
11
+ images: true,
12
+ }),
13
+ });
14
+
15
+ const configQueues = new Map();
16
+
17
+ function normalizeFeatures(features) {
18
+ return Object.fromEntries(FEATURE_IDS.map((id) => [
19
+ id,
20
+ typeof features?.[id] === "boolean" ? features[id] : true,
21
+ ]));
22
+ }
23
+
24
+ export function normalizeConfig(value = {}) {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ throw new Error("MineCodex config must be a JSON object");
27
+ }
28
+ if (value.schemaVersion !== undefined && value.schemaVersion !== 1) {
29
+ throw new Error(`Unsupported MineCodex config schema: ${value.schemaVersion}`);
30
+ }
31
+ return {
32
+ ...value,
33
+ schemaVersion: 1,
34
+ features: {
35
+ ...(value.features && typeof value.features === "object" ? value.features : {}),
36
+ ...normalizeFeatures(value.features),
37
+ },
38
+ };
39
+ }
40
+
41
+ export async function readConfig(configPath) {
42
+ try {
43
+ return normalizeConfig(JSON.parse(await readFile(configPath, "utf8")));
44
+ } catch (error) {
45
+ if (error.code === "ENOENT") return normalizeConfig(DEFAULT_CONFIG);
46
+ if (error instanceof SyntaxError) throw new Error(`Invalid MineCodex config JSON: ${error.message}`);
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ export async function writeConfig(configPath, value) {
52
+ const config = normalizeConfig(value);
53
+ await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
54
+ const temporaryPath = `${configPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
55
+ await writeFile(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
56
+ await rename(temporaryPath, configPath);
57
+ return config;
58
+ }
59
+
60
+ export async function ensureConfig(configPath) {
61
+ const config = await readConfig(configPath);
62
+ await writeConfig(configPath, config);
63
+ return config;
64
+ }
65
+
66
+ function withConfigLock(configPath, operation) {
67
+ const previous = configQueues.get(configPath) ?? Promise.resolve();
68
+ const operationPromise = previous.catch(() => {}).then(operation);
69
+ const settled = operationPromise.finally(() => {
70
+ if (configQueues.get(configPath) === settled) configQueues.delete(configPath);
71
+ });
72
+ configQueues.set(configPath, settled);
73
+ return settled;
74
+ }
75
+
76
+ export async function updateFeature(configPath, id, enabled, {
77
+ read = readConfig,
78
+ write = writeConfig,
79
+ } = {}) {
80
+ if (!FEATURE_IDS.includes(id)) throw new Error(`Unknown MineCodex plugin: ${id}`);
81
+ if (typeof enabled !== "boolean") throw new Error("enabled must be a boolean");
82
+ return withConfigLock(configPath, async () => {
83
+ const config = await read(configPath);
84
+ config.features[id] = enabled;
85
+ return write(configPath, config);
86
+ });
87
+ }
88
+
89
+ export async function ensureControlToken(tokenPath) {
90
+ try {
91
+ return (await readFile(tokenPath, "utf8")).trim();
92
+ } catch (error) {
93
+ if (error.code !== "ENOENT") throw error;
94
+ }
95
+ await mkdir(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
96
+ const token = randomBytes(32).toString("base64url");
97
+ await writeFile(tokenPath, `${token}\n`, { mode: 0o600, flag: "wx" }).catch(async (error) => {
98
+ if (error.code !== "EEXIST") throw error;
99
+ });
100
+ return (await readFile(tokenPath, "utf8")).trim();
101
+ }