@codextheme/cli 0.2.0 → 0.2.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
@@ -5,22 +5,22 @@ Fixed-version, one-command curated and private custom skins for Codex Desktop on
5
5
  Apply a catalog skin:
6
6
 
7
7
  ```sh
8
- npx --yes @codextheme/cli@0.2.0 apply cathedral-nocturne
8
+ npx --yes @codextheme/cli@0.2.1 apply cathedral-nocturne
9
9
  ```
10
10
 
11
11
  The custom skin studio at [codextheme.tech](https://codextheme.tech) creates an expiring private command:
12
12
 
13
13
  ```sh
14
- npx --yes @codextheme/cli@0.2.0 apply-private <private-id>
14
+ npx --yes @codextheme/cli@0.2.1 apply-private <private-id>
15
15
  ```
16
16
 
17
17
  Private packages are downloaded only from the fixed `https://codextheme.tech` origin, bounded, integrity-checked, schema-validated, safety-linted, and cached locally with owner-only permissions. The temporary server link expires after 24 hours; `reapply` uses the validated local cache and works after that link expires.
18
18
 
19
19
  ```sh
20
- npx --yes @codextheme/cli@0.2.0 reapply
21
- npx --yes @codextheme/cli@0.2.0 restore
20
+ npx --yes @codextheme/cli@0.2.1 reapply
21
+ npx --yes @codextheme/cli@0.2.1 restore
22
22
  ```
23
23
 
24
- The CLI has no install scripts, does not use `sudo`, and does not modify the Codex application bundle. Requirements: macOS, Node.js 22.4+, and Codex Desktop. A running Codex process is never closed or reopened without an explicit `y` confirmation.
24
+ The CLI has no install scripts, does not use `sudo`, and does not modify the Codex application bundle. Requirements: macOS, Node.js 22.4+, and Codex Desktop. A running Codex process is never closed or reopened without an explicit `y` confirmation. After confirmation, version 0.2.1 uses an owner-only detached one-shot worker so an apply started inside Codex survives that restart; it does not install a persistent service.
25
25
 
26
26
  CodexTheme is an independent project and is not affiliated with or endorsed by OpenAI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codextheme/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "One-command curated and private custom skins for Codex Desktop on macOS.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,146 @@
1
+ import { execFile } from "node:child_process";
2
+ import { applyPrivateTheme, applyTheme } from "./lifecycle.mjs";
3
+
4
+ const PUBLIC_FAILURES = new Map([
5
+ ["E_CODEX_NOT_FOUND", "没有找到已安装的 Codex Desktop。"],
6
+ ["E_DOM_INCOMPATIBLE", "当前 Codex 界面结构与该主题暂不兼容。"],
7
+ ["E_CORE_VERIFY", "主题应用后的完整性验证失败。"],
8
+ ["E_PRIVATE_CACHE", "无法安全读取本地私有主题缓存。"],
9
+ ["E_RESTORE_FAILED", "主题应用失败,且未能完整恢复 Codex 官方外观。"],
10
+ ["E_HANDOFF_BUSY", "已有一个 Codex 重启任务正在处理。"],
11
+ ["E_HANDOFF_FAILED", "CodexTheme 未能完成后台主题应用。"],
12
+ ]);
13
+
14
+ function delay(milliseconds) {
15
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
16
+ }
17
+
18
+ function processIsAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (error) {
23
+ return error?.code === "EPERM";
24
+ }
25
+ }
26
+
27
+ export async function waitForProcessExit(
28
+ pid,
29
+ { isAlive = processIsAlive, wait = delay, timeoutMs = 10000, pollMs = 100 } = {},
30
+ ) {
31
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
32
+ throw Object.assign(new Error("Invalid handoff parent process."), { code: "E_HANDOFF_FAILED" });
33
+ }
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ if (!isAlive(pid)) return;
37
+ await wait(pollMs);
38
+ }
39
+ throw Object.assign(new Error("Foreground CLI did not exit before handoff."), { code: "E_HANDOFF_FAILED" });
40
+ }
41
+
42
+ export async function notifyHandoff(status, { execFileApi = execFile } = {}) {
43
+ const message = status === "success"
44
+ ? "Theme applied. Codex has reopened."
45
+ : "Theme apply failed. Open CodexTheme Help for recovery steps.";
46
+ const script = `display notification ${JSON.stringify(message)} with title "CodexTheme"`;
47
+ await new Promise((resolve) => {
48
+ execFileApi("/usr/bin/osascript", ["-e", script], () => resolve());
49
+ });
50
+ }
51
+
52
+ function publicFailure(error) {
53
+ const code = PUBLIC_FAILURES.has(error?.code) ? error.code : "E_HANDOFF_FAILED";
54
+ return { code, message: PUBLIC_FAILURES.get(code) };
55
+ }
56
+
57
+ export function validateWorkerInvocation(argv, pendingPath) {
58
+ if (!Array.isArray(argv) || argv.length !== 2 || argv[0] !== pendingPath || !/^[1-9][0-9]*$/.test(argv[1])) {
59
+ throw Object.assign(new Error("Invalid handoff worker invocation."), { code: "E_HANDOFF_FAILED" });
60
+ }
61
+ const parentPid = Number(argv[1]);
62
+ if (!Number.isSafeInteger(parentPid)) {
63
+ throw Object.assign(new Error("Invalid handoff worker invocation."), { code: "E_HANDOFF_FAILED" });
64
+ }
65
+ return parentPid;
66
+ }
67
+
68
+ async function applyJob({ job, lifecycle, cache, runtime, stateStore, now, onApplyStart }) {
69
+ const shared = {
70
+ runtime,
71
+ stateStore,
72
+ promptRestart: async () => true,
73
+ now,
74
+ };
75
+ if (job.action.source === "catalog") {
76
+ onApplyStart();
77
+ return lifecycle.applyTheme({ ...shared, slug: job.action.themeSlug });
78
+ }
79
+ const bundle = JSON.parse(await cache.read(job.action.cacheKey));
80
+ onApplyStart();
81
+ return lifecycle.applyPrivateTheme({
82
+ ...shared,
83
+ bundle,
84
+ cacheKey: job.action.cacheKey,
85
+ });
86
+ }
87
+
88
+ export async function runHandoffJob({
89
+ store,
90
+ runtime,
91
+ stateStore,
92
+ cache,
93
+ lifecycle = { applyTheme, applyPrivateTheme },
94
+ waitForParentExit = waitForProcessExit,
95
+ settleAfterParentExit = () => delay(1500),
96
+ notify = notifyHandoff,
97
+ now = () => new Date(),
98
+ }) {
99
+ let exitCode = 1;
100
+ let applyStarted = false;
101
+ try {
102
+ const job = await store.readPending();
103
+ await waitForParentExit(job.parentPid);
104
+ await settleAfterParentExit();
105
+ await applyJob({
106
+ job,
107
+ lifecycle,
108
+ cache,
109
+ runtime,
110
+ stateStore,
111
+ now,
112
+ onApplyStart: () => { applyStarted = true; },
113
+ });
114
+ await store.writeResult({
115
+ schemaVersion: 1,
116
+ status: "success",
117
+ completedAt: now().toISOString(),
118
+ message: "主题已应用并通过验证。",
119
+ });
120
+ await notify("success").catch(() => {});
121
+ exitCode = 0;
122
+ } catch (error) {
123
+ let recovered = !applyStarted;
124
+ if (applyStarted) {
125
+ try {
126
+ const recovery = await runtime.recover();
127
+ recovered = recovery?.recovered === true;
128
+ } catch { /* The sanitized result below records failed recovery. */ }
129
+ }
130
+ const failure = publicFailure(error);
131
+ try {
132
+ await store.writeResult({
133
+ schemaVersion: 1,
134
+ status: "failure",
135
+ completedAt: now().toISOString(),
136
+ code: failure.code,
137
+ message: failure.message,
138
+ recovered,
139
+ });
140
+ } catch { /* Notification still gives the user a recovery signal. */ }
141
+ await notify("failure").catch(() => {});
142
+ } finally {
143
+ await store.removePending().catch(() => {});
144
+ }
145
+ return exitCode;
146
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createPrivateCache } from "./cache.mjs";
4
+ import { createHandoffStore } from "./handoff.mjs";
5
+ import { runHandoffJob, validateWorkerInvocation } from "./handoff-runner.mjs";
6
+ import { runtime } from "./runtime.mjs";
7
+ import { createStateStore } from "./state.mjs";
8
+
9
+ const store = createHandoffStore();
10
+
11
+ try {
12
+ const parentPid = validateWorkerInvocation(process.argv.slice(2), store.pendingPath);
13
+ const pending = await store.readPending();
14
+ if (pending.parentPid !== parentPid) {
15
+ throw Object.assign(new Error("Handoff parent mismatch."), { code: "E_HANDOFF_FAILED" });
16
+ }
17
+ process.exitCode = await runHandoffJob({
18
+ store,
19
+ runtime,
20
+ stateStore: createStateStore(),
21
+ cache: createPrivateCache(),
22
+ });
23
+ } catch {
24
+ process.exitCode = 1;
25
+ }
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const SAFE_CACHE_KEY = /^[a-f0-9]{64}$/;
9
+ const MAX_PENDING_AGE_MS = 5 * 60 * 1000;
10
+
11
+ function handoffError(code, message) {
12
+ return Object.assign(new Error(message), { code });
13
+ }
14
+
15
+ function exactKeys(value, keys) {
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
17
+ const actual = Object.keys(value).sort();
18
+ const expected = [...keys].sort();
19
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
20
+ }
21
+
22
+ function cleanAction(value) {
23
+ if (
24
+ exactKeys(value, ["schemaVersion", "source", "themeSlug"])
25
+ && value.schemaVersion === 2
26
+ && value.source === "catalog"
27
+ && typeof value.themeSlug === "string"
28
+ && SAFE_SLUG.test(value.themeSlug)
29
+ ) {
30
+ return { source: "catalog", themeSlug: value.themeSlug };
31
+ }
32
+ if (
33
+ exactKeys(value, ["schemaVersion", "source", "cacheKey"])
34
+ && value.schemaVersion === 2
35
+ && value.source === "private"
36
+ && typeof value.cacheKey === "string"
37
+ && SAFE_CACHE_KEY.test(value.cacheKey)
38
+ ) {
39
+ return { source: "private", cacheKey: value.cacheKey };
40
+ }
41
+ throw handoffError("E_HANDOFF_FAILED", "无法创建安全的 Codex 重启任务。");
42
+ }
43
+
44
+ function cleanStoredAction(value) {
45
+ if (value?.source === "catalog" && exactKeys(value, ["source", "themeSlug"])) {
46
+ return cleanAction({ schemaVersion: 2, ...value });
47
+ }
48
+ if (value?.source === "private" && exactKeys(value, ["source", "cacheKey"])) {
49
+ return cleanAction({ schemaVersion: 2, ...value });
50
+ }
51
+ throw handoffError("E_HANDOFF_FAILED", "本地 Codex 重启任务无效。");
52
+ }
53
+
54
+ function cleanJob(value) {
55
+ if (
56
+ !exactKeys(value, ["schemaVersion", "createdAt", "parentPid", "action"])
57
+ || value.schemaVersion !== 1
58
+ || typeof value.createdAt !== "string"
59
+ || !Number.isFinite(Date.parse(value.createdAt))
60
+ || !Number.isSafeInteger(value.parentPid)
61
+ || value.parentPid <= 0
62
+ ) {
63
+ throw handoffError("E_HANDOFF_FAILED", "本地 Codex 重启任务无效。");
64
+ }
65
+ const action = cleanStoredAction(value.action);
66
+ return {
67
+ schemaVersion: 1,
68
+ createdAt: value.createdAt,
69
+ parentPid: value.parentPid,
70
+ action,
71
+ };
72
+ }
73
+
74
+ function cleanResult(value) {
75
+ if (
76
+ value?.schemaVersion !== 1
77
+ || !["success", "failure"].includes(value.status)
78
+ || typeof value.completedAt !== "string"
79
+ || !Number.isFinite(Date.parse(value.completedAt))
80
+ || typeof value.message !== "string"
81
+ || !value.message
82
+ ) {
83
+ throw handoffError("E_HANDOFF_FAILED", "Codex 重启任务结果无效。");
84
+ }
85
+ if (value.status === "success" && exactKeys(value, ["schemaVersion", "status", "completedAt", "message"])) {
86
+ return { schemaVersion: 1, status: "success", completedAt: value.completedAt, message: value.message };
87
+ }
88
+ if (
89
+ value.status === "failure"
90
+ && exactKeys(value, ["schemaVersion", "status", "completedAt", "code", "message", "recovered"])
91
+ && typeof value.code === "string"
92
+ && /^E_[A-Z0-9_]+$/.test(value.code)
93
+ && typeof value.recovered === "boolean"
94
+ ) {
95
+ return {
96
+ schemaVersion: 1,
97
+ status: "failure",
98
+ completedAt: value.completedAt,
99
+ code: value.code,
100
+ message: value.message,
101
+ recovered: value.recovered,
102
+ };
103
+ }
104
+ throw handoffError("E_HANDOFF_FAILED", "Codex 重启任务结果无效。");
105
+ }
106
+
107
+ async function ensureDirectory(directory, fsApi) {
108
+ await fsApi.mkdir(directory, { recursive: true, mode: 0o700 });
109
+ await fsApi.chmod(directory, 0o700);
110
+ }
111
+
112
+ async function createExclusive(filename, serialized, fsApi) {
113
+ const temporary = `${filename}.${process.pid}.${Date.now()}.tmp`;
114
+ try {
115
+ await fsApi.writeFile(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
116
+ await fsApi.link(temporary, filename);
117
+ await fsApi.chmod(filename, 0o600);
118
+ } finally {
119
+ await fsApi.rm(temporary, { force: true }).catch(() => {});
120
+ }
121
+ }
122
+
123
+ async function replaceFile(filename, serialized, fsApi) {
124
+ const temporary = `${filename}.${process.pid}.${Date.now()}.tmp`;
125
+ try {
126
+ await fsApi.writeFile(temporary, serialized, { encoding: "utf8", mode: 0o600 });
127
+ await fsApi.rename(temporary, filename);
128
+ await fsApi.chmod(filename, 0o600);
129
+ } finally {
130
+ await fsApi.rm(temporary, { force: true }).catch(() => {});
131
+ }
132
+ }
133
+
134
+ export function createHandoffStore({ home = os.homedir(), fsApi = fs, now = () => new Date() } = {}) {
135
+ const directory = path.join(home, "Library", "Application Support", "codextheme", "handoff");
136
+ const pendingPath = path.join(directory, "pending.json");
137
+ const resultPath = path.join(directory, "last-result.json");
138
+
139
+ return {
140
+ directory,
141
+ pendingPath,
142
+ resultPath,
143
+
144
+ async createPending(action, parentPid) {
145
+ const createdAt = now().toISOString();
146
+ const job = cleanJob({ schemaVersion: 1, createdAt, parentPid, action: cleanAction(action) });
147
+ await ensureDirectory(directory, fsApi);
148
+ for (let attempt = 0; attempt < 2; attempt += 1) {
149
+ try {
150
+ await createExclusive(pendingPath, `${JSON.stringify(job, null, 2)}\n`, fsApi);
151
+ return job;
152
+ } catch (error) {
153
+ if (error?.code !== "EEXIST") {
154
+ if (String(error?.code ?? "").startsWith("E_HANDOFF_")) throw error;
155
+ throw handoffError("E_HANDOFF_FAILED", "无法写入本地 Codex 重启任务。");
156
+ }
157
+ let existing;
158
+ try {
159
+ existing = await this.readPending();
160
+ } catch {
161
+ throw handoffError("E_HANDOFF_BUSY", "已有一个 Codex 重启任务正在处理。");
162
+ }
163
+ if (now().getTime() - Date.parse(existing.createdAt) <= MAX_PENDING_AGE_MS) {
164
+ throw handoffError("E_HANDOFF_BUSY", "已有一个 Codex 重启任务正在处理。");
165
+ }
166
+ await fsApi.rm(pendingPath, { force: true });
167
+ }
168
+ }
169
+ throw handoffError("E_HANDOFF_BUSY", "已有一个 Codex 重启任务正在处理。");
170
+ },
171
+
172
+ async readPending() {
173
+ try {
174
+ return cleanJob(JSON.parse(await fsApi.readFile(pendingPath, "utf8")));
175
+ } catch (error) {
176
+ if (String(error?.code ?? "").startsWith("E_HANDOFF_")) throw error;
177
+ throw handoffError("E_HANDOFF_FAILED", "无法安全读取本地 Codex 重启任务。");
178
+ }
179
+ },
180
+
181
+ async writeResult(value) {
182
+ const result = cleanResult(value);
183
+ await ensureDirectory(directory, fsApi);
184
+ await replaceFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, fsApi);
185
+ return result;
186
+ },
187
+
188
+ async removePending() {
189
+ await fsApi.rm(pendingPath, { force: true });
190
+ },
191
+ };
192
+ }
193
+
194
+ export function createRestartHandoff({
195
+ home = os.homedir(),
196
+ fsApi = fs,
197
+ now = () => new Date(),
198
+ spawnProcess = spawn,
199
+ execPath = process.execPath,
200
+ workerPath = fileURLToPath(new URL("./handoff-worker.mjs", import.meta.url)),
201
+ parentPid = process.pid,
202
+ } = {}) {
203
+ const store = createHandoffStore({ home, fsApi, now });
204
+ return {
205
+ store,
206
+ async schedule(action) {
207
+ await store.createPending(action, parentPid);
208
+ try {
209
+ const child = spawnProcess(
210
+ execPath,
211
+ [workerPath, store.pendingPath, String(parentPid)],
212
+ { detached: true, stdio: "ignore" },
213
+ );
214
+ if (!child || typeof child.unref !== "function") throw new Error("invalid child");
215
+ child.unref();
216
+ } catch {
217
+ await store.removePending().catch(() => {});
218
+ throw handoffError("E_HANDOFF_FAILED", "无法启动独立的 Codex 重启任务。");
219
+ }
220
+ return { queued: true, resultPath: store.resultPath };
221
+ },
222
+ };
223
+ }
package/src/lifecycle.mjs CHANGED
@@ -32,7 +32,15 @@ async function requireRestartConsent(promptRestart) {
32
32
  }
33
33
  }
34
34
 
35
- async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promptRestart, now }) {
35
+ async function delegateRestart({ theme, nextState, restartHandoff }) {
36
+ return {
37
+ theme,
38
+ handoff: await restartHandoff.schedule(nextState),
39
+ appliedAt: null,
40
+ };
41
+ }
42
+
43
+ async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promptRestart, restartHandoff, now }) {
36
44
  let detection;
37
45
  try {
38
46
  detection = await runtime.detect();
@@ -46,6 +54,7 @@ async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promp
46
54
  let restartExisting = false;
47
55
  if (detection.running && !detection.ready) {
48
56
  await requireRestartConsent(promptRestart);
57
+ if (restartHandoff) return delegateRestart({ theme, nextState, restartHandoff });
49
58
  restartExisting = true;
50
59
  }
51
60
 
@@ -55,6 +64,7 @@ async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promp
55
64
  } catch (error) {
56
65
  if (error?.code === "CODEDROBE_RESTART_REQUIRED" && !restartExisting) {
57
66
  await requireRestartConsent(promptRestart);
67
+ if (restartHandoff) return delegateRestart({ theme, nextState, restartHandoff });
58
68
  restartExisting = true;
59
69
  try {
60
70
  result = await runtime.apply({ theme, restartExisting });
@@ -75,7 +85,7 @@ async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promp
75
85
  return { theme, result, appliedAt };
76
86
  }
77
87
 
78
- export async function applyTheme({ slug, runtime, stateStore, promptRestart, now = () => new Date() }) {
88
+ export async function applyTheme({ slug, runtime, stateStore, promptRestart, restartHandoff, now = () => new Date() }) {
79
89
  let theme;
80
90
  try {
81
91
  theme = await runtime.loadTheme(slug);
@@ -88,11 +98,12 @@ export async function applyTheme({ slug, runtime, stateStore, promptRestart, now
88
98
  runtime,
89
99
  stateStore,
90
100
  promptRestart,
101
+ restartHandoff,
91
102
  now,
92
103
  });
93
104
  }
94
105
 
95
- export async function applyPrivateTheme({ bundle, cacheKey, runtime, stateStore, promptRestart, now = () => new Date() }) {
106
+ export async function applyPrivateTheme({ bundle, cacheKey, runtime, stateStore, promptRestart, restartHandoff, now = () => new Date() }) {
96
107
  let theme;
97
108
  try {
98
109
  theme = await runtime.loadThemeBundle(bundle);
@@ -105,6 +116,7 @@ export async function applyPrivateTheme({ bundle, cacheKey, runtime, stateStore,
105
116
  runtime,
106
117
  stateStore,
107
118
  promptRestart,
119
+ restartHandoff,
108
120
  now,
109
121
  });
110
122
  }
package/src/main.mjs CHANGED
@@ -1,14 +1,15 @@
1
1
  import { CATALOG, getCatalogEntry } from "./catalog.mjs";
2
2
  import { createPrivateCache } from "./cache.mjs";
3
+ import { createRestartHandoff } from "./handoff.mjs";
3
4
  import { applyPrivateTheme, applyTheme, CliError, reapplyTheme, restoreTheme } from "./lifecycle.mjs";
4
5
  import { privateThemeSource } from "./private-source.mjs";
5
6
  import { confirmRestart } from "./prompt.mjs";
6
7
  import { runtime as productionRuntime } from "./runtime.mjs";
7
8
  import { createStateStore } from "./state.mjs";
8
9
 
9
- export const VERSION = "0.2.0";
10
- const REAPPLY = "npx --yes @codextheme/cli@0.2.0 reapply";
11
- const RESTORE = "npx --yes @codextheme/cli@0.2.0 restore";
10
+ export const VERSION = "0.2.1";
11
+ const REAPPLY = "npx --yes @codextheme/cli@0.2.1 reapply";
12
+ const RESTORE = "npx --yes @codextheme/cli@0.2.1 restore";
12
13
 
13
14
  const HELP = `CodexTheme ${VERSION}
14
15
 
@@ -40,11 +41,26 @@ function safePublicError(error) {
40
41
  "E_PRIVATE_DOWNLOAD",
41
42
  "E_PRIVATE_INVALID",
42
43
  "E_PRIVATE_CACHE",
44
+ "E_HANDOFF_BUSY",
45
+ "E_HANDOFF_FAILED",
43
46
  ]);
44
47
  if (allowed.has(error?.code)) return { code: error.code, message: error.message, exitCode: 1 };
45
48
  return { code: "E_DOM_INCOMPATIBLE", message: "CodexTheme 未能完成本次操作。", exitCode: 1 };
46
49
  }
47
50
 
51
+ function writeApplyResult(stdout, result, name, successMessage, { showReapply = true } = {}) {
52
+ if (result.handoff?.queued) {
53
+ stdout.write(
54
+ `✓ ${name} 已交给独立任务处理\n`
55
+ + "Codex 将自动关闭并重新打开一次,随后完成主题应用和验证。\n"
56
+ + "当前 Codex task 可能结束;最终结果会通过 macOS 通知显示。\n",
57
+ );
58
+ return;
59
+ }
60
+ const reapply = showReapply ? `重开 Codex 后运行:${REAPPLY}\n` : "";
61
+ stdout.write(`${successMessage}\n${reapply}恢复官方外观:${RESTORE}\n`);
62
+ }
63
+
48
64
  function validateCommand(argv) {
49
65
  if (argv.length === 2 && argv[0] === "apply") {
50
66
  const theme = getCatalogEntry(argv[1]);
@@ -75,7 +91,7 @@ export async function run(argv, dependencies = {}) {
75
91
  try {
76
92
  const parsed = validateCommand(argv);
77
93
  if ((dependencies.platform ?? process.platform) !== "darwin") {
78
- throw new CliError("E_PLATFORM", "0.2.0 版本仅支持 macOS 上的 Codex Desktop。");
94
+ throw new CliError("E_PLATFORM", `${VERSION} 版本仅支持 macOS 上的 Codex Desktop。`);
79
95
  }
80
96
  const services = {
81
97
  runtime: dependencies.runtime ?? productionRuntime,
@@ -83,23 +99,24 @@ export async function run(argv, dependencies = {}) {
83
99
  promptRestart: dependencies.promptRestart ?? (() => confirmRestart()),
84
100
  now: dependencies.now ?? (() => new Date()),
85
101
  cache: dependencies.privateCache ?? createPrivateCache(),
102
+ restartHandoff: dependencies.restartHandoff ?? createRestartHandoff(),
86
103
  };
87
104
  const privateSource = dependencies.privateSource ?? privateThemeSource;
88
105
 
89
106
  if (parsed.command === "apply") {
90
107
  const result = await applyTheme({ ...services, slug: parsed.slug });
91
108
  const name = result.theme.theme?.displayName?.split(" / ")[0] ?? getCatalogEntry(parsed.slug).name;
92
- stdout.write(`✓ ${name} 主题已应用并通过验证\n重开 Codex 后运行:${REAPPLY}\n恢复官方外观:${RESTORE}\n`);
109
+ writeApplyResult(stdout, result, name, `✓ ${name} 主题已应用并通过验证`);
93
110
  } else if (parsed.command === "apply-private") {
94
111
  const downloaded = await privateSource.download(parsed.privateId);
95
112
  const cacheKey = await services.cache.write(downloaded.serialized);
96
113
  const result = await applyPrivateTheme({ ...services, bundle: downloaded.bundle, cacheKey });
97
114
  const name = result.theme.theme?.displayName ?? "Private Custom Skin";
98
- stdout.write(`✓ ${name} 已应用并通过验证\n重开 Codex 后运行:${REAPPLY}\n恢复官方外观:${RESTORE}\n`);
115
+ writeApplyResult(stdout, result, name, `✓ ${name} 已应用并通过验证`);
99
116
  } else if (parsed.command === "reapply") {
100
117
  const result = await reapplyTheme(services);
101
118
  const name = result.theme.theme?.displayName?.split(" / ")[0] ?? "当前";
102
- stdout.write(`✓ ${name} 主题已重新应用并通过验证\n恢复官方外观:${RESTORE}\n`);
119
+ writeApplyResult(stdout, result, name, `✓ ${name} 主题已重新应用并通过验证`, { showReapply: false });
103
120
  } else {
104
121
  await restoreTheme(services);
105
122
  stdout.write("✓ 已恢复 Codex 官方外观\n");
package/src/runtime.mjs CHANGED
@@ -1,67 +1,100 @@
1
- import {
2
- applySkin,
3
- discoverApp,
4
- findRunningPids,
5
- findTargets,
6
- getAdapter,
7
- lintThemePackage,
8
- readThemePackage,
9
- resolveThemeTarget,
10
- restoreSkin,
11
- validateThemePackage,
12
- } from "@codextheme/runtime";
1
+ import * as runtimeCore from "@codextheme/runtime";
13
2
  import { themeFilename } from "./catalog.mjs";
14
3
 
15
- const adapter = getAdapter("codex");
4
+ const RENDERER_ABSENT_CODES = new Set(["CODEDROBE_TARGET_TIMEOUT", "ECONNREFUSED"]);
16
5
 
17
- function resolveSafeTheme(bundle) {
18
- try {
19
- validateThemePackage(bundle);
20
- if (lintThemePackage(bundle).length) throw new Error("lint");
21
- return resolveThemeTarget(bundle, adapter.id);
22
- } catch {
23
- throw Object.assign(new Error("Theme failed safety validation."), { code: "E_DOM_INCOMPATIBLE" });
24
- }
6
+ function restoreComplete(result) {
7
+ const rendererComplete = result?.renderer?.restored === true
8
+ || RENDERER_ABSENT_CODES.has(result?.renderer?.code);
9
+ const hostComplete = result?.host?.restored === true
10
+ || result?.host?.reason === "missing-backup";
11
+ return rendererComplete && hostComplete;
25
12
  }
26
13
 
27
- export const runtime = {
28
- async loadTheme(slug) {
29
- const filename = themeFilename(slug);
30
- if (!filename) throw Object.assign(new Error("Unknown theme."), { code: "E_USAGE" });
31
- const bundle = await readThemePackage(filename);
32
- return resolveSafeTheme(bundle);
33
- },
14
+ export function createRuntime({ core = runtimeCore, platform = process.platform } = {}) {
15
+ const adapter = core.getAdapter("codex");
34
16
 
35
- async loadThemeBundle(bundle) {
36
- return resolveSafeTheme(bundle);
37
- },
17
+ function resolveSafeTheme(bundle) {
18
+ try {
19
+ core.validateThemePackage(bundle);
20
+ if (core.lintThemePackage(bundle).length) throw new Error("lint");
21
+ return core.resolveThemeTarget(bundle, adapter.id);
22
+ } catch {
23
+ throw Object.assign(new Error("Theme failed safety validation."), { code: "E_DOM_INCOMPATIBLE" });
24
+ }
25
+ }
38
26
 
39
- async detect() {
40
- const discovered = await discoverApp(adapter);
27
+ async function detectCodex() {
28
+ const discovered = await core.discoverApp(adapter, platform);
41
29
  if (!discovered) return { installed: false, running: false, ready: false };
42
30
  let ready = false;
43
31
  try {
44
- ready = (await findTargets(adapter, adapter.defaultPort)).length > 0;
32
+ ready = (await core.findTargets(adapter, adapter.defaultPort)).length > 0;
45
33
  } catch { /* An absent loopback endpoint is an expected detection result. */ }
46
34
  let running = ready;
47
35
  if (!running) {
48
36
  try {
49
- running = (await findRunningPids(adapter, process.platform, discovered.executable)).length > 0;
37
+ running = (await core.findRunningPids(adapter, platform, discovered.executable)).length > 0;
50
38
  } catch { /* applySkin still performs the authoritative launch check. */ }
51
39
  }
52
40
  return { installed: true, running, ready };
53
- },
41
+ }
42
+
43
+ return {
44
+ async loadTheme(slug) {
45
+ const filename = themeFilename(slug);
46
+ if (!filename) throw Object.assign(new Error("Unknown theme."), { code: "E_USAGE" });
47
+ const bundle = await core.readThemePackage(filename);
48
+ return resolveSafeTheme(bundle);
49
+ },
50
+
51
+ async loadThemeBundle(bundle) {
52
+ return resolveSafeTheme(bundle);
53
+ },
54
+
55
+ detect: detectCodex,
54
56
 
55
- async apply({ theme, restartExisting }) {
56
- return applySkin({
57
- adapter,
58
- targetTheme: theme,
59
- launch: true,
60
- restartExisting,
61
- });
62
- },
57
+ async apply({ theme, restartExisting }) {
58
+ return core.applySkin({
59
+ adapter,
60
+ targetTheme: theme,
61
+ launch: true,
62
+ restartExisting,
63
+ });
64
+ },
65
+
66
+ async restore() {
67
+ return core.restoreSkin({ adapter });
68
+ },
69
+
70
+ async recover() {
71
+ let restored = null;
72
+ try {
73
+ restored = await core.restoreSkin({ adapter });
74
+ } catch { /* Relaunch still runs even when restore itself fails. */ }
75
+
76
+ let detection;
77
+ try {
78
+ detection = await detectCodex();
79
+ } catch {
80
+ detection = { installed: false, running: false, ready: false };
81
+ }
82
+
83
+ let launch = null;
84
+ if (detection.installed && !detection.running) {
85
+ try {
86
+ launch = await core.launchApp({ adapter });
87
+ } catch { /* The returned recovery result remains false. */ }
88
+ }
89
+
90
+ const running = detection.running || Boolean(launch?.targets);
91
+ return {
92
+ recovered: restoreComplete(restored) && running,
93
+ restore: restored,
94
+ launch,
95
+ };
96
+ },
97
+ };
98
+ }
63
99
 
64
- async restore() {
65
- return restoreSkin({ adapter });
66
- },
67
- };
100
+ export const runtime = createRuntime();