minecodex 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,10 @@ npm install -g minecodex
17
17
  mcx install
18
18
  ```
19
19
 
20
+ `mcx install` 会关闭当前正在运行的 Codex 窗口,再以 MineCodex 托管方式重新打开;
21
+ 请先保存正在编辑的内容,并让安装命令完整运行到成功提示。安装后,macOS 的
22
+ “登录项与扩展”中会显示名为 **MineCodex** 的后台项目,用于在登录后恢复插件。
23
+
20
24
  安装后三个插件默认开启。打开本地控制台:
21
25
 
22
26
  ```bash
@@ -34,7 +38,8 @@ mcx status [--json]
34
38
  mcx restart
35
39
  ```
36
40
 
37
- 关闭插件只会停止注册,不会删除数据。`mcx uninstall` 默认保留 MineCodex 设置、
41
+ 关闭插件只会停用对应功能,不会删除数据;保存开关后运行 `mcx restart` 应用变更。
42
+ `mcx uninstall` 会移除名为 MineCodex 的后台项目,并默认保留 MineCodex 设置、
38
43
  Notes 数据和 Images 索引;只有显式传入 `--purge` 才会删除 MineCodex 自有数据。
39
44
  MineCodex 永远不会删除 `~/.codex/generated_images` 中的原图或 Notes 引用的源文件。
40
45
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "images",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "label": {
6
6
  "en": "Images",
7
7
  "zh-CN": "图片"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "model-slider",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "label": {
6
6
  "en": "Model Slider",
7
7
  "zh-CN": "模型滑条"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "notes",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "label": {
6
6
  "en": "Notes",
7
7
  "zh-CN": "笔记"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,6 +16,7 @@
16
16
  "LICENSE",
17
17
  "packages/cli/bin",
18
18
  "packages/cli/src",
19
+ "packages/cli/native/MineCodex.app",
19
20
  "packages/runtime-host/src",
20
21
  "features/model-slider/assets",
21
22
  "features/model-slider/codex-feature.json",
@@ -39,10 +40,11 @@
39
40
  },
40
41
  "scripts": {
41
42
  "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": "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 apps/dashboard/test/*.test.mjs",
43
44
  "test:cli": "node --test packages/cli/test/*.test.mjs",
44
45
  "test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
45
46
  "check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
47
+ "build:service": "scripts/build-service-app.zsh",
46
48
  "pack:check": "npm pack --dry-run",
47
49
  "pack:audit": "node scripts/audit-package.mjs",
48
50
  "prepublishOnly": "npm test && npm run check && npm run pack:audit"
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>MineCodex</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>MineCodexServiceManager</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>com.vontean.minecodex.background-service-manager</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>MineCodex</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>APPL</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>1.0.0</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>1</string>
23
+ <key>LSBackgroundOnly</key>
24
+ <true/>
25
+ <key>LSMinimumSystemVersion</key>
26
+ <string>13.0</string>
27
+ </dict>
28
+ </plist>
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.vontean.minecodex.background</string>
7
+ <key>BundleProgram</key>
8
+ <string>Contents/Resources/MineCodexServiceHost</string>
9
+ <key>RunAtLoad</key>
10
+ <true/>
11
+ <key>KeepAlive</key>
12
+ <dict>
13
+ <key>SuccessfulExit</key>
14
+ <false/>
15
+ </dict>
16
+ <key>ProcessType</key>
17
+ <string>Interactive</string>
18
+ </dict>
19
+ </plist>
@@ -0,0 +1,135 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/MineCodexServiceHost</key>
8
+ <data>
9
+ kB0iYBcULzqcS1yOgYpY3LZjeq4=
10
+ </data>
11
+ </dict>
12
+ <key>files2</key>
13
+ <dict>
14
+ <key>Library/LaunchAgents/com.vontean.minecodex.background.plist</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ mGWkf+X17Pau3uy8xUUB1s+JXofeKYKNRzFPk3FXeaA=
19
+ </data>
20
+ </dict>
21
+ <key>Resources/MineCodexServiceHost</key>
22
+ <dict>
23
+ <key>hash2</key>
24
+ <data>
25
+ b9bnzjgASA30QyXiczJMxHbOYKm7WG+j24ZfZ1povzc=
26
+ </data>
27
+ </dict>
28
+ </dict>
29
+ <key>rules</key>
30
+ <dict>
31
+ <key>^Resources/</key>
32
+ <true/>
33
+ <key>^Resources/.*\.lproj/</key>
34
+ <dict>
35
+ <key>optional</key>
36
+ <true/>
37
+ <key>weight</key>
38
+ <real>1000</real>
39
+ </dict>
40
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
41
+ <dict>
42
+ <key>omit</key>
43
+ <true/>
44
+ <key>weight</key>
45
+ <real>1100</real>
46
+ </dict>
47
+ <key>^Resources/Base\.lproj/</key>
48
+ <dict>
49
+ <key>weight</key>
50
+ <real>1010</real>
51
+ </dict>
52
+ <key>^version.plist$</key>
53
+ <true/>
54
+ </dict>
55
+ <key>rules2</key>
56
+ <dict>
57
+ <key>.*\.dSYM($|/)</key>
58
+ <dict>
59
+ <key>weight</key>
60
+ <real>11</real>
61
+ </dict>
62
+ <key>^(.*/)?\.DS_Store$</key>
63
+ <dict>
64
+ <key>omit</key>
65
+ <true/>
66
+ <key>weight</key>
67
+ <real>2000</real>
68
+ </dict>
69
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
70
+ <dict>
71
+ <key>nested</key>
72
+ <true/>
73
+ <key>weight</key>
74
+ <real>10</real>
75
+ </dict>
76
+ <key>^.*</key>
77
+ <true/>
78
+ <key>^Info\.plist$</key>
79
+ <dict>
80
+ <key>omit</key>
81
+ <true/>
82
+ <key>weight</key>
83
+ <real>20</real>
84
+ </dict>
85
+ <key>^PkgInfo$</key>
86
+ <dict>
87
+ <key>omit</key>
88
+ <true/>
89
+ <key>weight</key>
90
+ <real>20</real>
91
+ </dict>
92
+ <key>^Resources/</key>
93
+ <dict>
94
+ <key>weight</key>
95
+ <real>20</real>
96
+ </dict>
97
+ <key>^Resources/.*\.lproj/</key>
98
+ <dict>
99
+ <key>optional</key>
100
+ <true/>
101
+ <key>weight</key>
102
+ <real>1000</real>
103
+ </dict>
104
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
105
+ <dict>
106
+ <key>omit</key>
107
+ <true/>
108
+ <key>weight</key>
109
+ <real>1100</real>
110
+ </dict>
111
+ <key>^Resources/Base\.lproj/</key>
112
+ <dict>
113
+ <key>weight</key>
114
+ <real>1010</real>
115
+ </dict>
116
+ <key>^[^/]+$</key>
117
+ <dict>
118
+ <key>nested</key>
119
+ <true/>
120
+ <key>weight</key>
121
+ <real>10</real>
122
+ </dict>
123
+ <key>^embedded\.provisionprofile$</key>
124
+ <dict>
125
+ <key>weight</key>
126
+ <real>20</real>
127
+ </dict>
128
+ <key>^version\.plist$</key>
129
+ <dict>
130
+ <key>weight</key>
131
+ <real>20</real>
132
+ </dict>
133
+ </dict>
134
+ </dict>
135
+ </plist>
@@ -0,0 +1,18 @@
1
+ # Stable MineCodex background host
2
+
3
+ `MineCodex.app` is the small, universal native owner of MineCodex's macOS
4
+ background item. The npm package ships the built app only; native source and the
5
+ build script remain development inputs.
6
+
7
+ The production app bundle is intentionally versioned independently from the npm
8
+ package. Its ad-hoc code identity must stay byte-for-byte stable across ordinary
9
+ JavaScript releases because macOS records the helper's designated requirement.
10
+ Do not rebuild or change the bundle for an npm version bump. If native behavior
11
+ must change, plan an explicit signed-helper migration or introduce a new service
12
+ identifier and verify the macOS Background Items upgrade path before release.
13
+
14
+ Rebuild deliberately with:
15
+
16
+ ```bash
17
+ npm run build:service
18
+ ```
@@ -23,6 +23,12 @@ async function exists(filePath) {
23
23
  }
24
24
  }
25
25
 
26
+ async function serviceInstalled(platform, paths) {
27
+ if (platform.isServiceInstalled) return platform.isServiceInstalled(paths);
28
+ return (paths.serviceManagerPath ? exists(paths.serviceManagerPath) : false)
29
+ || (paths.launchAgentPath ? exists(paths.launchAgentPath) : false);
30
+ }
31
+
26
32
  async function waitForService(port, { fetchImpl = fetch, timeoutMs = 20_000 } = {}) {
27
33
  const deadline = Date.now() + timeoutMs;
28
34
  while (Date.now() < deadline) {
@@ -151,7 +157,7 @@ async function installCommand({ paths, platform, cliPath, output, serviceProbe,
151
157
  async function openCommand({ paths, platform, serviceProbe, fetchImpl }) {
152
158
  const waitForServiceImpl = serviceProbe?.waitForService ?? waitForService;
153
159
  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.");
160
+ if (!(await serviceInstalled(platform, paths))) throw new Error("MineCodex is not installed. Run `mcx install` first.");
155
161
  await platform.startService(paths);
156
162
  if (!(await waitForServiceImpl(DEFAULT_CONTROL_PORT, { fetchImpl }))) throw new Error("MineCodex service could not be started.");
157
163
  }
@@ -181,16 +187,16 @@ function featureStatusText(feature) {
181
187
  return `${copy[state] ?? state}${failure}`;
182
188
  }
183
189
 
184
- async function statusCommand({ paths, jsonOutput, output, fetchImpl }) {
190
+ async function statusCommand({ paths, platform, jsonOutput, output, fetchImpl }) {
185
191
  let status;
186
192
  try {
187
193
  const response = await authorizedFetch(paths, "/api/status", {}, { fetchImpl });
188
194
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
189
- status = { installed: await exists(paths.launchAgentPath), service: { running: true }, ...await response.json() };
195
+ status = { installed: await serviceInstalled(platform, paths), service: { running: true }, ...await response.json() };
190
196
  } catch {
191
197
  const config = await readConfig(paths.configPath).catch(() => null);
192
198
  status = {
193
- installed: await exists(paths.launchAgentPath),
199
+ installed: await serviceInstalled(platform, paths),
194
200
  version: packageMetadata.version,
195
201
  service: { running: false },
196
202
  runtime: { running: false },
@@ -267,7 +273,7 @@ async function updateCommand({ paths, platform, cliPath, output, npm = createNpm
267
273
  output(`MineCodex ${packageMetadata.version} is already up to date.`);
268
274
  return;
269
275
  }
270
- const wasInstalled = await exists(paths.launchAgentPath);
276
+ const wasInstalled = await serviceInstalled(platform, paths);
271
277
  const previousService = wasInstalled
272
278
  ? (await serviceSnapshot(platform, paths) ?? { running: true, plist: { exists: true } })
273
279
  : null;
@@ -276,7 +282,7 @@ async function updateCommand({ paths, platform, cliPath, output, npm = createNpm
276
282
  if (!rollbackPath) throw new Error("Could not create a rollback archive for the installed MineCodex version.");
277
283
  try {
278
284
  if (wasInstalled && previousService?.running) {
279
- await platform.stopService({ ignoreErrors: false });
285
+ await platform.stopService(paths, { ignoreErrors: false });
280
286
  await platform.assertServiceStopped?.();
281
287
  }
282
288
  await npm.installGlobal("minecodex@latest");
@@ -378,7 +384,7 @@ export async function runCli(argv, {
378
384
  if (["--version", "-v"].includes(command)) return output(packageMetadata.version);
379
385
  if (command === "install") return installCommand({ paths, platform, cliPath, output, serviceProbe, fetchImpl, nodeVersion });
380
386
  if (command === "open" || command === "gui") return openCommand({ paths, platform, serviceProbe, fetchImpl });
381
- if (command === "status") return statusCommand({ paths, jsonOutput: args.includes("--json"), output, fetchImpl });
387
+ if (command === "status") return statusCommand({ paths, platform, jsonOutput: args.includes("--json"), output, fetchImpl });
382
388
  if (command === "restart") return restartCommand({ paths, output, fetchImpl });
383
389
  if (command === "uninstall" || command === "remove") {
384
390
  return uninstallCommand({ paths, platform, purge: args.includes("--purge"), output, cliPath });
@@ -10,6 +10,8 @@ export const PACKAGE_ROOT = path.resolve(
10
10
  export function resolvePaths({ homeDir = os.homedir() } = {}) {
11
11
  const supportDir = path.join(homeDir, "Library", "Application Support", "MineCodex");
12
12
  const logsDir = path.join(homeDir, "Library", "Logs", "MineCodex");
13
+ const serviceDir = path.join(supportDir, "service");
14
+ const serviceAppPath = path.join(serviceDir, "MineCodex.app");
13
15
  return Object.freeze({
14
16
  homeDir,
15
17
  packageRoot: PACKAGE_ROOT,
@@ -18,12 +20,18 @@ export function resolvePaths({ homeDir = os.homedir() } = {}) {
18
20
  runtimeEntry: path.join(PACKAGE_ROOT, "packages", "runtime-host", "src", "main.mjs"),
19
21
  supportDir,
20
22
  logsDir,
23
+ serviceDir,
24
+ serviceBundleSource: path.join(PACKAGE_ROOT, "packages", "cli", "native", "MineCodex.app"),
25
+ serviceAppPath,
26
+ serviceManagerPath: path.join(serviceAppPath, "Contents", "MacOS", "MineCodexServiceManager"),
27
+ serviceRuntimeConfigPath: path.join(supportDir, "service-runtime.plist"),
21
28
  configPath: path.join(supportDir, "config.json"),
22
29
  tokenPath: path.join(supportDir, "control-token"),
23
30
  pidPath: path.join(supportDir, "service.pid"),
24
31
  codexPidPath: path.join(supportDir, "codex.pid"),
25
32
  runtimeReadyPath: path.join(supportDir, "runtime-ready.json"),
26
33
  profileDir: path.join(supportDir, "codex-profile"),
34
+ // v0.1.0 used a directly installed plist. Keep the path only for migration and cleanup.
27
35
  launchAgentPath: path.join(homeDir, "Library", "LaunchAgents", "com.vontean.minecodex.plist"),
28
36
  serviceLogPath: path.join(logsDir, "runtime.log"),
29
37
  serviceErrorLogPath: path.join(logsDir, "runtime-error.log"),
@@ -1,16 +1,18 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
- import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
 
7
7
  const defaultExecFile = promisify(execFileCallback);
8
- const defaultFileSystem = Object.freeze({ mkdir, readFile, stat, unlink, writeFile });
8
+ const defaultFileSystem = Object.freeze({ mkdir, readFile, rename, rm, stat, unlink, writeFile });
9
9
 
10
- export const SERVICE_LABEL = "com.vontean.minecodex";
10
+ export const SERVICE_LABEL = "com.vontean.minecodex.background";
11
+ export const LEGACY_SERVICE_LABEL = "com.vontean.minecodex";
11
12
  export const CODEX_APP_PATH = "/Applications/ChatGPT.app";
12
13
  export const CODEX_EXECUTABLE = `${CODEX_APP_PATH}/Contents/MacOS/ChatGPT`;
13
14
  export const MIN_NODE_VERSION = Object.freeze({ major: 22, minor: 5, patch: 0 });
15
+ export const MIN_MACOS_VERSION = Object.freeze({ major: 13, darwinMajor: 22 });
14
16
 
15
17
  function xml(value) {
16
18
  return String(value)
@@ -21,38 +23,21 @@ function xml(value) {
21
23
  .replaceAll("'", "&apos;");
22
24
  }
23
25
 
24
- export function buildLaunchAgent({ nodePath, cliPath, paths }) {
26
+ export function buildServiceRuntimeConfig({ nodePath, cliPath, paths }) {
25
27
  return `<?xml version="1.0" encoding="UTF-8"?>
26
28
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
27
29
  <plist version="1.0">
28
30
  <dict>
29
- <key>Label</key>
30
- <string>${SERVICE_LABEL}</string>
31
- <key>ProgramArguments</key>
32
- <array>
33
- <string>${xml(nodePath)}</string>
34
- <string>${xml(cliPath)}</string>
35
- <string>serve</string>
36
- <string>--service</string>
37
- </array>
38
- <key>RunAtLoad</key>
39
- <true/>
40
- <key>KeepAlive</key>
41
- <dict>
42
- <key>SuccessfulExit</key>
43
- <false/>
44
- </dict>
45
- <key>ProcessType</key>
46
- <string>Interactive</string>
47
- <key>StandardOutPath</key>
31
+ <key>NodePath</key>
32
+ <string>${xml(nodePath)}</string>
33
+ <key>CLIPath</key>
34
+ <string>${xml(cliPath)}</string>
35
+ <key>StandardOutputPath</key>
48
36
  <string>${xml(paths.serviceLogPath)}</string>
49
37
  <key>StandardErrorPath</key>
50
38
  <string>${xml(paths.serviceErrorLogPath)}</string>
51
- <key>EnvironmentVariables</key>
52
- <dict>
53
- <key>PATH</key>
54
- <string>${xml(path.dirname(nodePath))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
55
- </dict>
39
+ <key>Path</key>
40
+ <string>${xml(path.dirname(nodePath))}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
56
41
  </dict>
57
42
  </plist>
58
43
  `;
@@ -155,10 +140,31 @@ function serviceTarget(uid) {
155
140
  return `gui/${uid}/${SERVICE_LABEL}`;
156
141
  }
157
142
 
143
+ function legacyServiceTarget(uid) {
144
+ return `gui/${uid}/${LEGACY_SERVICE_LABEL}`;
145
+ }
146
+
147
+ function appServiceStatus(output) {
148
+ const match = String(output).match(/(?:^|\n)status=([a-z-]+)(?:\n|$)/);
149
+ return match?.[1] ?? "unknown";
150
+ }
151
+
152
+ function assertOwnedServicePaths(paths) {
153
+ const root = path.parse(paths.serviceDir ?? "").root;
154
+ if (!path.isAbsolute(paths.serviceDir)
155
+ || paths.serviceDir === root
156
+ || path.dirname(paths.serviceDir) === root
157
+ || path.basename(paths.serviceDir) !== "service"
158
+ || paths.serviceAppPath !== path.join(paths.serviceDir, "MineCodex.app")) {
159
+ throw new Error(`Refusing unsafe MineCodex service path: ${paths.serviceAppPath}`);
160
+ }
161
+ }
162
+
158
163
  export class MacPlatformAdapter {
159
164
  constructor({
160
165
  uid = process.getuid?.() ?? os.userInfo().uid,
161
166
  platform = process.platform,
167
+ osRelease = os.release(),
162
168
  homeDir = os.homedir(),
163
169
  execFile = defaultExecFile,
164
170
  fileSystem = defaultFileSystem,
@@ -168,6 +174,7 @@ export class MacPlatformAdapter {
168
174
  } = {}) {
169
175
  this.uid = uid;
170
176
  this.platform = platform;
177
+ this.osRelease = osRelease;
171
178
  this.homeDir = homeDir;
172
179
  this.execFile = execFile;
173
180
  this.fileSystem = fileSystem;
@@ -180,6 +187,10 @@ export class MacPlatformAdapter {
180
187
  if (this.platform !== "darwin") {
181
188
  throw new Error("MineCodex v1 installation currently supports macOS only.");
182
189
  }
190
+ const darwinMajor = Number.parseInt(this.osRelease.split(".")[0], 10);
191
+ if (!Number.isInteger(darwinMajor) || darwinMajor < MIN_MACOS_VERSION.darwinMajor) {
192
+ throw new Error(`MineCodex requires macOS ${MIN_MACOS_VERSION.major} or newer.`);
193
+ }
183
194
  }
184
195
 
185
196
  async assertCodexInstalled() {
@@ -202,9 +213,9 @@ export class MacPlatformAdapter {
202
213
  return this.codexInstallation ?? this.assertCodexInstalled();
203
214
  }
204
215
 
205
- async isServiceRunning() {
216
+ async isLaunchdServiceRunning(target) {
206
217
  try {
207
- await this.execFile("/bin/launchctl", ["print", serviceTarget(this.uid)]);
218
+ await this.execFile("/bin/launchctl", ["print", target]);
208
219
  return true;
209
220
  } catch (error) {
210
221
  if (serviceIsAbsent(error)) return false;
@@ -212,6 +223,41 @@ export class MacPlatformAdapter {
212
223
  }
213
224
  }
214
225
 
226
+ async isServiceRunning() {
227
+ return this.isLaunchdServiceRunning(serviceTarget(this.uid));
228
+ }
229
+
230
+ async pathExists(filePath) {
231
+ try {
232
+ if (this.fileSystem.stat) await this.fileSystem.stat(filePath);
233
+ else await this.fileSystem.readFile(filePath);
234
+ return true;
235
+ } catch (error) {
236
+ if (error.code === "ENOENT") return false;
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ async serviceManagerStatus(paths) {
242
+ if (!(await this.pathExists(paths.serviceManagerPath))) return "not-found";
243
+ const { stdout } = await this.execFile(paths.serviceManagerPath, ["status"]);
244
+ return appServiceStatus(stdout);
245
+ }
246
+
247
+ async isServiceInstalled(paths) {
248
+ return (await this.pathExists(paths.serviceManagerPath))
249
+ || (await this.pathExists(paths.launchAgentPath));
250
+ }
251
+
252
+ async writeServiceRuntimeConfig(paths, content, mode = 0o600) {
253
+ const temporaryPath = `${paths.serviceRuntimeConfigPath}.installing`;
254
+ await this.fileSystem.unlink(temporaryPath).catch((error) => {
255
+ if (error.code !== "ENOENT") throw error;
256
+ });
257
+ await this.fileSystem.writeFile(temporaryPath, content, { mode });
258
+ await this.fileSystem.rename(temporaryPath, paths.serviceRuntimeConfigPath);
259
+ }
260
+
215
261
  async snapshotServiceState(paths) {
216
262
  let plist;
217
263
  try {
@@ -227,45 +273,123 @@ export class MacPlatformAdapter {
227
273
  if (error.code !== "ENOENT") throw error;
228
274
  plist = { exists: false, content: null, mode: null };
229
275
  }
230
- return { plist, running: await this.isServiceRunning() };
276
+ const appExists = await this.pathExists(paths.serviceManagerPath);
277
+ let runtimeConfig = { exists: false, content: null, mode: null };
278
+ if (appExists && paths.serviceRuntimeConfigPath) {
279
+ try {
280
+ const metadataPromise = this.fileSystem.stat
281
+ ? this.fileSystem.stat(paths.serviceRuntimeConfigPath).catch(() => null)
282
+ : Promise.resolve(null);
283
+ const [content, metadata] = await Promise.all([
284
+ this.fileSystem.readFile(paths.serviceRuntimeConfigPath, "utf8"),
285
+ metadataPromise,
286
+ ]);
287
+ runtimeConfig = { exists: true, content, mode: metadata?.mode };
288
+ } catch (error) {
289
+ if (error.code !== "ENOENT") throw error;
290
+ }
291
+ }
292
+ const running = await this.isServiceRunning()
293
+ || (plist.exists && await this.isLaunchdServiceRunning(legacyServiceTarget(this.uid)));
294
+ return {
295
+ app: {
296
+ exists: appExists,
297
+ status: appExists ? await this.serviceManagerStatus(paths) : "not-found",
298
+ runtimeConfig,
299
+ },
300
+ plist,
301
+ running,
302
+ };
231
303
  }
232
304
 
233
305
  async installService({ cliPath, paths, start = true }) {
234
- await this.fileSystem.mkdir(path.dirname(paths.launchAgentPath), { recursive: true });
306
+ const stagingAppPath = path.join(paths.serviceDir, ".MineCodex.app.installing");
307
+ assertOwnedServicePaths(paths);
308
+ await this.stopService(paths, { ignoreErrors: false });
309
+ await this.fileSystem.mkdir(paths.serviceDir, { recursive: true, mode: 0o700 });
235
310
  await this.fileSystem.mkdir(paths.logsDir, { recursive: true, mode: 0o700 });
236
- const plist = buildLaunchAgent({ nodePath: process.execPath, cliPath, paths });
237
- await this.fileSystem.writeFile(paths.launchAgentPath, plist, { mode: 0o600 });
238
- await this.stopService({ ignoreErrors: true });
239
- await this.execFile("/bin/launchctl", ["bootstrap", `gui/${this.uid}`, paths.launchAgentPath]);
240
- if (start) {
241
- await this.execFile("/bin/launchctl", ["kickstart", "-k", serviceTarget(this.uid)]);
242
- } else {
243
- await this.stopService({ ignoreErrors: true });
311
+ await this.writeServiceRuntimeConfig(
312
+ paths,
313
+ buildServiceRuntimeConfig({ nodePath: process.execPath, cliPath, paths }),
314
+ );
315
+ await this.fileSystem.rm(stagingAppPath, { recursive: true, force: true });
316
+ try {
317
+ await this.execFile("/usr/bin/ditto", [paths.serviceBundleSource, stagingAppPath]);
318
+ await this.execFile("/usr/bin/codesign", ["--verify", "--deep", "--strict", stagingAppPath]);
319
+ await this.fileSystem.rm(paths.serviceAppPath, { recursive: true, force: true });
320
+ await this.fileSystem.rename(stagingAppPath, paths.serviceAppPath);
321
+ } catch (error) {
322
+ await this.fileSystem.rm(stagingAppPath, { recursive: true, force: true }).catch(() => {});
323
+ throw error;
244
324
  }
325
+ await this.fileSystem.unlink(paths.launchAgentPath).catch((error) => {
326
+ if (error.code !== "ENOENT") throw error;
327
+ });
328
+ if (start) await this.startService(paths);
245
329
  }
246
330
 
247
- async stopService({ ignoreErrors = false } = {}) {
331
+ async stopService(paths, { ignoreErrors = false } = {}) {
332
+ let managerError = null;
333
+ if (paths?.serviceManagerPath && await this.pathExists(paths.serviceManagerPath)) {
334
+ try {
335
+ await this.execFile(paths.serviceManagerPath, ["unregister"]);
336
+ } catch (error) {
337
+ managerError = error;
338
+ }
339
+ }
248
340
  try {
249
341
  await this.execFile("/bin/launchctl", ["bootout", serviceTarget(this.uid)]);
250
342
  } catch (error) {
251
- if (!ignoreErrors || !serviceIsAbsent(error)) throw error;
343
+ if (!serviceIsAbsent(error)) throw error;
252
344
  }
345
+ try {
346
+ await this.execFile("/bin/launchctl", ["bootout", legacyServiceTarget(this.uid)]);
347
+ } catch (error) {
348
+ if (!serviceIsAbsent(error)) throw error;
349
+ }
350
+ if (managerError && !ignoreErrors) throw managerError;
253
351
  }
254
352
 
255
353
  async startService(paths) {
256
- if (!(await this.isServiceRunning())) {
257
- await this.execFile("/bin/launchctl", ["bootstrap", `gui/${this.uid}`, paths.launchAgentPath]);
354
+ const status = await this.serviceManagerStatus(paths);
355
+ if (status === "requires-approval") {
356
+ throw new Error(
357
+ "MineCodex background service is disabled. Enable MineCodex in System Settings > General > Login Items & Extensions, then run `mcx install` again.",
358
+ );
359
+ }
360
+ if (status !== "enabled") {
361
+ const { stdout } = await this.execFile(paths.serviceManagerPath, ["register"]);
362
+ const registeredStatus = appServiceStatus(stdout);
363
+ if (registeredStatus !== "enabled") {
364
+ throw new Error(`MineCodex background service registration requires attention: ${registeredStatus}`);
365
+ }
258
366
  }
259
367
  await this.execFile("/bin/launchctl", ["kickstart", "-k", serviceTarget(this.uid)]);
260
368
  }
261
369
 
262
370
  async assertServiceStopped() {
263
- if (await this.isServiceRunning()) throw new Error("MineCodex launchd service is still running.");
371
+ const [current, legacy] = await Promise.all([
372
+ this.isServiceRunning(),
373
+ this.isLaunchdServiceRunning(legacyServiceTarget(this.uid)),
374
+ ]);
375
+ if (current || legacy) throw new Error("MineCodex launchd service is still running.");
264
376
  }
265
377
 
266
378
  async restoreServiceState(snapshot, { cliPath, paths } = {}) {
267
- await this.stopService({ ignoreErrors: true });
379
+ await this.removeService(paths);
268
380
  await this.assertServiceStopped();
381
+ if (snapshot?.app?.exists) {
382
+ await this.installService({ cliPath, paths, start: false });
383
+ if (snapshot.app.runtimeConfig?.exists) {
384
+ await this.writeServiceRuntimeConfig(
385
+ paths,
386
+ snapshot.app.runtimeConfig.content,
387
+ snapshot.app.runtimeConfig.mode ? snapshot.app.runtimeConfig.mode & 0o777 : 0o600,
388
+ );
389
+ }
390
+ if (snapshot.app.status === "enabled") await this.startService(paths);
391
+ return snapshot;
392
+ }
269
393
  if (snapshot?.plist?.exists) {
270
394
  await this.fileSystem.mkdir(path.dirname(paths.launchAgentPath), { recursive: true });
271
395
  await this.fileSystem.writeFile(paths.launchAgentPath, snapshot.plist.content, {
@@ -276,20 +400,29 @@ export class MacPlatformAdapter {
276
400
  if (error.code !== "ENOENT") throw error;
277
401
  });
278
402
  }
279
- if (snapshot?.running) {
280
- if (!snapshot?.plist?.exists) throw new Error("Cannot restore a running service without its plist.");
403
+ if (snapshot?.running && snapshot?.plist?.exists) {
281
404
  await this.execFile("/bin/launchctl", ["bootstrap", `gui/${this.uid}`, paths.launchAgentPath]);
282
- await this.execFile("/bin/launchctl", ["kickstart", "-k", serviceTarget(this.uid)]);
405
+ await this.execFile("/bin/launchctl", ["kickstart", "-k", legacyServiceTarget(this.uid)]);
283
406
  }
284
407
  return snapshot;
285
408
  }
286
409
 
287
410
  async removeService(paths) {
288
- await this.stopService({ ignoreErrors: true });
411
+ assertOwnedServicePaths(paths);
412
+ await this.stopService(paths, { ignoreErrors: false });
289
413
  await this.assertServiceStopped();
290
414
  await this.fileSystem.unlink(paths.launchAgentPath).catch((error) => {
291
415
  if (error.code !== "ENOENT") throw error;
292
416
  });
417
+ if (paths.serviceRuntimeConfigPath) {
418
+ await this.fileSystem.unlink(paths.serviceRuntimeConfigPath).catch((error) => {
419
+ if (error.code !== "ENOENT") throw error;
420
+ });
421
+ await this.fileSystem.unlink(`${paths.serviceRuntimeConfigPath}.installing`).catch((error) => {
422
+ if (error.code !== "ENOENT") throw error;
423
+ });
424
+ }
425
+ await this.fileSystem.rm(paths.serviceDir, { recursive: true, force: true });
293
426
  }
294
427
 
295
428
  async listCodexProcesses() {