@codextheme/cli 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,25 +1,26 @@
1
1
  # @codextheme/cli
2
2
 
3
- Fixed-version, one-command curated themes for Codex Desktop on macOS.
3
+ Fixed-version, one-command curated and private custom skins for Codex Desktop on macOS.
4
+
5
+ Apply a catalog skin:
4
6
 
5
7
  ```sh
6
- npx --yes @codextheme/cli@0.1.1 apply cathedral-nocturne
8
+ npx --yes @codextheme/cli@0.2.0 apply cathedral-nocturne
7
9
  ```
8
10
 
9
- Version 0.1.1 bundles three new original Gothic Worlds themes and keeps the three 0.1.0 theme slugs for backward compatibility. It does not download themes, run install scripts, use `sudo`, or modify the Codex application bundle.
10
-
11
- After restarting Codex, reapply the active theme with:
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.1.1 reapply
14
+ npx --yes @codextheme/cli@0.2.0 apply-private <private-id>
15
15
  ```
16
16
 
17
- Restore the official appearance with:
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.1.1 restore
20
+ npx --yes @codextheme/cli@0.2.0 reapply
21
+ npx --yes @codextheme/cli@0.2.0 restore
21
22
  ```
22
23
 
23
- 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.
24
25
 
25
26
  CodexTheme is an independent project and is not affiliated with or endorsed by OpenAI.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codextheme/cli",
3
- "version": "0.1.1",
4
- "description": "One-command curated themes for Codex Desktop on macOS.",
3
+ "version": "0.2.0",
4
+ "description": "One-command curated and private custom skins for Codex Desktop on macOS.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "bin": {
package/src/cache.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const SAFE_KEY = /^[a-f0-9]{64}$/;
7
+
8
+ function cacheError() {
9
+ return Object.assign(new Error("无法安全读写本地私有主题缓存。"), { code: "E_PRIVATE_CACHE" });
10
+ }
11
+
12
+ export function createPrivateCache({ home = os.homedir(), fsApi = fs } = {}) {
13
+ const directory = path.join(home, "Library", "Application Support", "codextheme", "private");
14
+ const filename = (key) => {
15
+ if (typeof key !== "string" || !SAFE_KEY.test(key)) throw cacheError();
16
+ return path.join(directory, `${key}.codextheme-theme`);
17
+ };
18
+
19
+ return {
20
+ directory,
21
+ filename,
22
+
23
+ async write(serialized) {
24
+ if (typeof serialized !== "string" || !serialized) throw cacheError();
25
+ const key = createHash("sha256").update(serialized).digest("hex");
26
+ const target = filename(key);
27
+ await fsApi.mkdir(directory, { recursive: true, mode: 0o700 });
28
+ await fsApi.chmod(directory, 0o700);
29
+ try {
30
+ const existing = await fsApi.readFile(target, "utf8");
31
+ if (createHash("sha256").update(existing).digest("hex") !== key) throw cacheError();
32
+ return key;
33
+ } catch (error) {
34
+ if (error?.code !== "ENOENT") throw error?.code === "E_PRIVATE_CACHE" ? error : cacheError();
35
+ }
36
+
37
+ const temporary = path.join(directory, `.private.${process.pid}.${Date.now()}.tmp`);
38
+ try {
39
+ await fsApi.writeFile(temporary, serialized, { encoding: "utf8", mode: 0o600 });
40
+ await fsApi.rename(temporary, target);
41
+ await fsApi.chmod(target, 0o600);
42
+ return key;
43
+ } catch {
44
+ await fsApi.rm(temporary, { force: true }).catch(() => {});
45
+ throw cacheError();
46
+ }
47
+ },
48
+
49
+ async read(key) {
50
+ let serialized;
51
+ try {
52
+ serialized = await fsApi.readFile(filename(key), "utf8");
53
+ } catch {
54
+ throw cacheError();
55
+ }
56
+ if (createHash("sha256").update(serialized).digest("hex") !== key) throw cacheError();
57
+ return serialized;
58
+ },
59
+
60
+ async remove(key) {
61
+ try {
62
+ await fsApi.rm(filename(key), { force: true });
63
+ } catch {
64
+ throw cacheError();
65
+ }
66
+ },
67
+ };
68
+ }
package/src/lifecycle.mjs CHANGED
@@ -32,11 +32,9 @@ async function requireRestartConsent(promptRestart) {
32
32
  }
33
33
  }
34
34
 
35
- export async function applyTheme({ slug, runtime, stateStore, promptRestart, now = () => new Date() }) {
36
- let theme;
35
+ async function applyResolvedTheme({ theme, nextState, runtime, stateStore, promptRestart, now }) {
37
36
  let detection;
38
37
  try {
39
- theme = await runtime.loadTheme(slug);
40
38
  detection = await runtime.detect();
41
39
  } catch (error) {
42
40
  throw mappedError(error);
@@ -73,14 +71,61 @@ export async function applyTheme({ slug, runtime, stateStore, promptRestart, now
73
71
  }
74
72
 
75
73
  const appliedAt = now().toISOString();
76
- await stateStore.write({ schemaVersion: 1, themeSlug: slug, appliedAt });
74
+ await stateStore.write({ ...nextState, appliedAt });
77
75
  return { theme, result, appliedAt };
78
76
  }
79
77
 
78
+ export async function applyTheme({ slug, runtime, stateStore, promptRestart, now = () => new Date() }) {
79
+ let theme;
80
+ try {
81
+ theme = await runtime.loadTheme(slug);
82
+ } catch (error) {
83
+ throw mappedError(error);
84
+ }
85
+ return applyResolvedTheme({
86
+ theme,
87
+ nextState: { schemaVersion: 2, source: "catalog", themeSlug: slug },
88
+ runtime,
89
+ stateStore,
90
+ promptRestart,
91
+ now,
92
+ });
93
+ }
94
+
95
+ export async function applyPrivateTheme({ bundle, cacheKey, runtime, stateStore, promptRestart, now = () => new Date() }) {
96
+ let theme;
97
+ try {
98
+ theme = await runtime.loadThemeBundle(bundle);
99
+ } catch (error) {
100
+ throw mappedError(error);
101
+ }
102
+ return applyResolvedTheme({
103
+ theme,
104
+ nextState: { schemaVersion: 2, source: "private", cacheKey },
105
+ runtime,
106
+ stateStore,
107
+ promptRestart,
108
+ now,
109
+ });
110
+ }
111
+
80
112
  export async function reapplyTheme(options) {
81
113
  const state = await options.stateStore.read();
82
114
  if (!state) throw new CliError("E_USAGE", "没有可重新应用的活动主题,请先从主题页复制 apply 命令。");
83
- return applyTheme({ ...options, slug: state.themeSlug });
115
+ if (state.schemaVersion === 1 || state.source === "catalog") {
116
+ return applyTheme({ ...options, slug: state.themeSlug });
117
+ }
118
+ if (state.schemaVersion !== 2 || state.source !== "private" || !options.cache) {
119
+ throw new CliError("E_USAGE", "活动主题状态无效,请重新创建或应用主题。");
120
+ }
121
+ let bundle;
122
+ try {
123
+ bundle = JSON.parse(await options.cache.read(state.cacheKey));
124
+ } catch (error) {
125
+ if (String(error?.code ?? "").startsWith("E_")) throw error;
126
+ throw new CliError("E_PRIVATE_CACHE", "无法安全读取本地私有主题缓存。");
127
+ }
128
+ return applyPrivateTheme({ ...options, bundle, cacheKey: state.cacheKey });
84
129
  }
85
130
 
86
131
  const RENDERER_ABSENT_CODES = new Set([
package/src/main.mjs CHANGED
@@ -1,17 +1,20 @@
1
1
  import { CATALOG, getCatalogEntry } from "./catalog.mjs";
2
- import { applyTheme, CliError, reapplyTheme, restoreTheme } from "./lifecycle.mjs";
2
+ import { createPrivateCache } from "./cache.mjs";
3
+ import { applyPrivateTheme, applyTheme, CliError, reapplyTheme, restoreTheme } from "./lifecycle.mjs";
4
+ import { privateThemeSource } from "./private-source.mjs";
3
5
  import { confirmRestart } from "./prompt.mjs";
4
6
  import { runtime as productionRuntime } from "./runtime.mjs";
5
7
  import { createStateStore } from "./state.mjs";
6
8
 
7
- export const VERSION = "0.1.1";
8
- const REAPPLY = "npx --yes @codextheme/cli@0.1.1 reapply";
9
- const RESTORE = "npx --yes @codextheme/cli@0.1.1 restore";
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
12
 
11
13
  const HELP = `CodexTheme ${VERSION}
12
14
 
13
15
  用法:
14
16
  codextheme apply <theme>
17
+ codextheme apply-private <private-id>
15
18
  codextheme reapply
16
19
  codextheme restore
17
20
 
@@ -32,6 +35,11 @@ function safePublicError(error) {
32
35
  "E_DOM_INCOMPATIBLE",
33
36
  "E_CORE_VERIFY",
34
37
  "E_RESTORE_FAILED",
38
+ "E_PRIVATE_NOT_FOUND",
39
+ "E_PRIVATE_EXPIRED",
40
+ "E_PRIVATE_DOWNLOAD",
41
+ "E_PRIVATE_INVALID",
42
+ "E_PRIVATE_CACHE",
35
43
  ]);
36
44
  if (allowed.has(error?.code)) return { code: error.code, message: error.message, exitCode: 1 };
37
45
  return { code: "E_DOM_INCOMPATIBLE", message: "CodexTheme 未能完成本次操作。", exitCode: 1 };
@@ -43,6 +51,9 @@ function validateCommand(argv) {
43
51
  if (!theme) throw usage("未知主题。请从 codextheme.tech 复制完整命令。");
44
52
  return { command: "apply", slug: theme.slug };
45
53
  }
54
+ if (argv.length === 2 && argv[0] === "apply-private") {
55
+ return { command: "apply-private", privateId: argv[1] };
56
+ }
46
57
  if (argv.length === 1 && argv[0] === "reapply") return { command: "reapply" };
47
58
  if (argv.length === 1 && argv[0] === "restore") return { command: "restore" };
48
59
  throw usage("参数无效。运行 codextheme --help 查看用法。");
@@ -64,19 +75,27 @@ export async function run(argv, dependencies = {}) {
64
75
  try {
65
76
  const parsed = validateCommand(argv);
66
77
  if ((dependencies.platform ?? process.platform) !== "darwin") {
67
- throw new CliError("E_PLATFORM", "0.1.1 首发版仅支持 macOS 上的 Codex Desktop。");
78
+ throw new CliError("E_PLATFORM", "0.2.0 版本仅支持 macOS 上的 Codex Desktop。");
68
79
  }
69
80
  const services = {
70
81
  runtime: dependencies.runtime ?? productionRuntime,
71
82
  stateStore: dependencies.stateStore ?? createStateStore(),
72
83
  promptRestart: dependencies.promptRestart ?? (() => confirmRestart()),
73
84
  now: dependencies.now ?? (() => new Date()),
85
+ cache: dependencies.privateCache ?? createPrivateCache(),
74
86
  };
87
+ const privateSource = dependencies.privateSource ?? privateThemeSource;
75
88
 
76
89
  if (parsed.command === "apply") {
77
90
  const result = await applyTheme({ ...services, slug: parsed.slug });
78
91
  const name = result.theme.theme?.displayName?.split(" / ")[0] ?? getCatalogEntry(parsed.slug).name;
79
92
  stdout.write(`✓ ${name} 主题已应用并通过验证\n重开 Codex 后运行:${REAPPLY}\n恢复官方外观:${RESTORE}\n`);
93
+ } else if (parsed.command === "apply-private") {
94
+ const downloaded = await privateSource.download(parsed.privateId);
95
+ const cacheKey = await services.cache.write(downloaded.serialized);
96
+ const result = await applyPrivateTheme({ ...services, bundle: downloaded.bundle, cacheKey });
97
+ const name = result.theme.theme?.displayName ?? "Private Custom Skin";
98
+ stdout.write(`✓ ${name} 已应用并通过验证\n重开 Codex 后运行:${REAPPLY}\n恢复官方外观:${RESTORE}\n`);
80
99
  } else if (parsed.command === "reapply") {
81
100
  const result = await reapplyTheme(services);
82
101
  const name = result.theme.theme?.displayName?.split(" / ")[0] ?? "当前";
@@ -0,0 +1,115 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lintThemePackage, validateThemePackage } from "@codextheme/runtime";
3
+
4
+ const PRODUCTION_ORIGIN = "https://codextheme.tech";
5
+ const SAFE_ID = /^[a-z0-9]+\.[A-Za-z0-9_-]{32}$/;
6
+ const SAFE_HASH = /^[a-f0-9]{64}$/;
7
+ const MAX_BYTES = 3_800_000;
8
+ const MEDIA_TYPE = "application/vnd.codextheme.theme+json";
9
+
10
+ function sourceError(code, message) {
11
+ return Object.assign(new Error(message), { code });
12
+ }
13
+
14
+ function exactKeys(value, expected) {
15
+ return value && typeof value === "object" && !Array.isArray(value)
16
+ && Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
17
+ }
18
+
19
+ function assertPrivateShape(bundle) {
20
+ if (!exactKeys(bundle, ["format", "schemaVersion", "exportedAt", "theme", "targets", "assets"])) throw new Error("root");
21
+ if (!exactKeys(bundle.theme, ["id", "displayName", "version", "copy"])) throw new Error("theme");
22
+ if (!exactKeys(bundle.theme.copy, ["brandTitle", "brandSubtitle", "signature", "tagline", "projectPrefix", "projectLabel", "ribbon"])) throw new Error("copy");
23
+ if (!exactKeys(bundle.targets, ["codex"])) throw new Error("targets");
24
+ const target = bundle.targets.codex;
25
+ if (!exactKeys(target, ["css", "options", "verification"])) throw new Error("target");
26
+ if (!exactKeys(target.options, ["rendererProfile", "baseTheme"])) throw new Error("options");
27
+ if (!exactKeys(target.options.baseTheme, ["mode", "codeTheme", "accent", "contrast", "ink", "surface", "opaqueWindows"])) throw new Error("base theme");
28
+ if (!exactKeys(target.verification, ["recommended"]) || !Array.isArray(target.verification.recommended)) throw new Error("verification");
29
+ for (const requirement of target.verification.recommended) {
30
+ if (!exactKeys(requirement, ["name", "any"])) throw new Error("requirement");
31
+ }
32
+ if (!exactKeys(bundle.assets, ["images"]) || !exactKeys(bundle.assets.images, ["hero", "session-bg"])) throw new Error("images");
33
+ for (const image of Object.values(bundle.assets.images)) {
34
+ if (!exactKeys(image, ["filename", "mimeType", "base64"]) || image.mimeType !== "image/webp" || image.filename !== "custom-skin.webp") {
35
+ throw new Error("image");
36
+ }
37
+ }
38
+ }
39
+
40
+ async function readBounded(response) {
41
+ if (!response.body) throw sourceError("E_PRIVATE_INVALID", "下载的私有主题无效。");
42
+ const reader = response.body.getReader();
43
+ const chunks = [];
44
+ let total = 0;
45
+ while (true) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ total += value.byteLength;
49
+ if (total > MAX_BYTES) {
50
+ void reader.cancel().catch(() => {});
51
+ throw sourceError("E_PRIVATE_INVALID", "下载的私有主题超过安全大小限制。");
52
+ }
53
+ chunks.push(value);
54
+ }
55
+ const combined = new Uint8Array(total);
56
+ let offset = 0;
57
+ for (const chunk of chunks) {
58
+ combined.set(chunk, offset);
59
+ offset += chunk.byteLength;
60
+ }
61
+ return new TextDecoder("utf-8", { fatal: true }).decode(combined);
62
+ }
63
+
64
+ export function createPrivateThemeSource({ origin = PRODUCTION_ORIGIN, fetchImpl = globalThis.fetch } = {}) {
65
+ return {
66
+ async download(id) {
67
+ if (typeof id !== "string" || !SAFE_ID.test(id)) {
68
+ throw sourceError("E_PRIVATE_NOT_FOUND", "没有找到该私有主题。");
69
+ }
70
+ let response;
71
+ try {
72
+ response = await fetchImpl(`${origin}/api/private-skins/${id}`, {
73
+ method: "GET",
74
+ redirect: "error",
75
+ signal: AbortSignal.timeout(15_000),
76
+ headers: { Accept: MEDIA_TYPE },
77
+ });
78
+ } catch {
79
+ throw sourceError("E_PRIVATE_DOWNLOAD", "暂时无法下载私有主题。");
80
+ }
81
+ if (response.status === 404) throw sourceError("E_PRIVATE_NOT_FOUND", "没有找到该私有主题。");
82
+ if (response.status === 410) throw sourceError("E_PRIVATE_EXPIRED", "该私有主题链接已过期。");
83
+ if (!response.ok) throw sourceError("E_PRIVATE_DOWNLOAD", "暂时无法下载私有主题。");
84
+ if (response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== MEDIA_TYPE) {
85
+ throw sourceError("E_PRIVATE_INVALID", "下载的私有主题类型无效。");
86
+ }
87
+
88
+ let serialized;
89
+ try {
90
+ serialized = await readBounded(response);
91
+ } catch (error) {
92
+ if (error?.code === "E_PRIVATE_INVALID") throw error;
93
+ throw sourceError("E_PRIVATE_INVALID", "下载的私有主题编码无效。");
94
+ }
95
+ const sha256 = createHash("sha256").update(serialized).digest("hex");
96
+ const expectedHash = response.headers.get("x-codextheme-sha256") ?? "";
97
+ if (!SAFE_HASH.test(expectedHash) || expectedHash !== sha256) {
98
+ throw sourceError("E_PRIVATE_INVALID", "私有主题完整性验证失败。");
99
+ }
100
+
101
+ let bundle;
102
+ try {
103
+ bundle = JSON.parse(serialized);
104
+ assertPrivateShape(bundle);
105
+ validateThemePackage(bundle);
106
+ if (lintThemePackage(bundle).length) throw new Error("lint");
107
+ } catch {
108
+ throw sourceError("E_PRIVATE_INVALID", "私有主题安全验证失败。");
109
+ }
110
+ return { serialized, sha256, bundle };
111
+ },
112
+ };
113
+ }
114
+
115
+ export const privateThemeSource = createPrivateThemeSource();
package/src/runtime.mjs CHANGED
@@ -8,21 +8,32 @@ import {
8
8
  readThemePackage,
9
9
  resolveThemeTarget,
10
10
  restoreSkin,
11
+ validateThemePackage,
11
12
  } from "@codextheme/runtime";
12
13
  import { themeFilename } from "./catalog.mjs";
13
14
 
14
15
  const adapter = getAdapter("codex");
15
16
 
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
+ }
25
+ }
26
+
16
27
  export const runtime = {
17
28
  async loadTheme(slug) {
18
29
  const filename = themeFilename(slug);
19
30
  if (!filename) throw Object.assign(new Error("Unknown theme."), { code: "E_USAGE" });
20
31
  const bundle = await readThemePackage(filename);
21
- const warnings = lintThemePackage(bundle);
22
- if (warnings.length) {
23
- throw Object.assign(new Error("Bundled theme failed safety lint."), { code: "E_DOM_INCOMPATIBLE" });
24
- }
25
- return resolveThemeTarget(bundle, adapter.id);
32
+ return resolveSafeTheme(bundle);
33
+ },
34
+
35
+ async loadThemeBundle(bundle) {
36
+ return resolveSafeTheme(bundle);
26
37
  },
27
38
 
28
39
  async detect() {
package/src/state.mjs CHANGED
@@ -2,21 +2,45 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ const SAFE_CACHE_KEY = /^[a-f0-9]{64}$/;
6
+
7
+ function invalidState() {
8
+ return Object.assign(new Error("Invalid CodexTheme state."), { code: "E_USAGE" });
9
+ }
10
+
5
11
  function cleanState(value) {
6
- if (
7
- value?.schemaVersion !== 1
8
- || typeof value.themeSlug !== "string"
9
- || !value.themeSlug
10
- || typeof value.appliedAt !== "string"
11
- || !value.appliedAt
12
- ) {
13
- throw Object.assign(new Error("Invalid CodexTheme state."), { code: "E_USAGE" });
12
+ if (typeof value?.appliedAt !== "string" || !value.appliedAt) throw invalidState();
13
+
14
+ if (value.schemaVersion === 1) {
15
+ if (typeof value.themeSlug !== "string" || !value.themeSlug) throw invalidState();
16
+ return {
17
+ schemaVersion: 1,
18
+ themeSlug: value.themeSlug,
19
+ appliedAt: value.appliedAt,
20
+ };
14
21
  }
15
- return {
16
- schemaVersion: 1,
17
- themeSlug: value.themeSlug,
18
- appliedAt: value.appliedAt,
19
- };
22
+
23
+ if (value.schemaVersion === 2 && value.source === "catalog") {
24
+ if (typeof value.themeSlug !== "string" || !value.themeSlug) throw invalidState();
25
+ return {
26
+ schemaVersion: 2,
27
+ source: "catalog",
28
+ themeSlug: value.themeSlug,
29
+ appliedAt: value.appliedAt,
30
+ };
31
+ }
32
+
33
+ if (value.schemaVersion === 2 && value.source === "private") {
34
+ if (typeof value.cacheKey !== "string" || !SAFE_CACHE_KEY.test(value.cacheKey)) throw invalidState();
35
+ return {
36
+ schemaVersion: 2,
37
+ source: "private",
38
+ cacheKey: value.cacheKey,
39
+ appliedAt: value.appliedAt,
40
+ };
41
+ }
42
+
43
+ throw invalidState();
20
44
  }
21
45
 
22
46
  export function createStateStore({ home = os.homedir(), fsApi = fs } = {}) {