codex-plus-patcher 0.2.1 → 0.3.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
@@ -21,7 +21,8 @@
21
21
 
22
22
  Codex Plus is an experimental local demonstrator for changes that can be layered onto an installed Codex desktop app without redistributing the app itself. The patcher is meant for technically curious users who want to inspect, test, or iterate on small binary patch sets against their own local copy.
23
23
 
24
- The current built-in patches:
24
+ The current built-in Codex Plus features are delivered by versioned ASAR
25
+ patches plus readable runtime plugins:
25
26
 
26
27
  - rename the copied app and add patch provenance to the About dialog
27
28
  - expose nested repositories in the Review pane
@@ -30,11 +31,21 @@ The current built-in patches:
30
31
  - add adaptive project colors for sidebar projects, grouped threads, pinned threads, user-message accents, and the composer
31
32
  - add the `Toggle sidebar blur` command palette entry to blur sidebar chat and project names for the current session
32
33
 
34
+ The generated app includes a readable Codex Plus runtime under
35
+ `webview/assets/codex-plus/`. Versioned ASAR patches install the runtime,
36
+ built-in plugins, and the small Codex core hooks those plugins use. See
37
+ [Runtime Plugin Support](docs/plugin-support.md) for the currently supported
38
+ plugin interfaces.
39
+
33
40
  ## How It Works
34
41
 
35
42
  The patcher reads the installed `Codex.app`, verifies the exact Codex version, bundle version, and original `Contents/Resources/app.asar` SHA-256, then selects the matching patch queue. Unsupported app versions fail closed so a patch written for one bundle is not applied to a different bundle by accident.
36
43
 
37
- When applying patches, the tool copies `Codex.app` to the target `Codex Plus.app`, rewrites selected packed ASAR files with ordered text transforms, updates bundle metadata such as the app name and identifier, refreshes Electron ASAR integrity metadata, and signs the copied app ad hoc. The source app is not modified.
44
+ When applying patches, the tool copies `Codex.app` to the target `Codex Plus.app`, rewrites selected packed ASAR files with ordered text transforms, adds Codex Plus runtime/plugin assets, updates bundle metadata such as the app name and identifier, refreshes Electron ASAR integrity metadata, and signs the copied app ad hoc. The source app is not modified.
45
+
46
+ The patch transforms should stay small where possible: their job is increasingly
47
+ to add reusable plugin interfaces and hook those interfaces into Codex core
48
+ surfaces, while feature behavior lives in readable runtime/plugin files.
38
49
 
39
50
  ## Disclaimer
40
51
 
@@ -115,6 +126,8 @@ Patch queues export `patchSets` from `index.js`. Each patch set declares:
115
126
  - an ordered `patches` array with stable patch IDs
116
127
  - optional bundle metadata updates for the copied app
117
128
  - ordered file transforms applied to packed ASAR files
129
+ - optional `assetFiles` entries added to the packed ASAR, used for runtime code,
130
+ built-in plugins, and plugin interface assets
118
131
 
119
132
  Unsupported app versions fail closed.
120
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plus-patcher",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Patch queue tool for building a local Codex Plus.app from an installed Codex.app.",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "test": "node --test",
24
- "check": "node --check src/cli.js && node --check src/core/asar.js && node --check src/core/patch-engine.js && node --check src/core/plist.js && node --check src/core/release.js && node --check src/patches/index.js && node --check src/patches/26.616.41845-4198.js && node --check src/patches/26.616.51431-4212.js && node --check src/patches/26.616.71553-4265.js && node --check src/plus/repositories.js"
24
+ "check": "node scripts/check-syntax.js"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const os = require("node:os");
3
3
  const path = require("node:path");
4
4
 
5
+ const { readAsar, walkFiles } = require("./core/asar");
5
6
  const { patchCodexApp } = require("./core/patch-engine");
6
7
  const { resolveReleasePatchDirectory } = require("./core/release");
7
8
  const { patchSets: builtInPatchSets } = require("./patches");
@@ -35,6 +36,9 @@ function parseArgs(argv) {
35
36
  };
36
37
  if (arg === "--source") args.source = path.resolve(expandPath(next()));
37
38
  else if (arg === "--target") args.target = path.resolve(expandPath(next()));
39
+ else if (arg === "--asar") args.asar = path.resolve(expandPath(next()));
40
+ else if (arg === "--file") args.file = next();
41
+ else if (arg === "--contains") args.contains = next();
38
42
  else if (arg === "--mode") args.mode = next();
39
43
  else if (arg === "--patch-dir") args.patchDir = path.resolve(expandPath(next()));
40
44
  else if (arg === "--github-repo") args.githubRepo = next();
@@ -58,10 +62,15 @@ function helpText() {
58
62
  return `Usage:
59
63
  codex-plus-patcher
60
64
  codex-plus-patcher apply [options]
65
+ codex-plus-patcher asar-list --asar <path> [--contains <text>] [--json]
66
+ codex-plus-patcher asar-cat --asar <path> --file <asar-path> [--json]
61
67
 
62
68
  Options:
63
69
  --source <path> Source Codex.app. Default: /Applications/Codex.app
64
70
  --target <path> Target Codex Plus.app. Default: ~/Applications/Codex Plus.app
71
+ --asar <path> app.asar path for ASAR readback commands
72
+ --file <asar-path> Packed file path for asar-cat
73
+ --contains <text> Filter asar-list paths by substring
65
74
  --mode <builtin|dev|release>
66
75
  --patch-dir <path> Dev mode patch directory containing index.js
67
76
  --github-repo <owner/repo>
@@ -125,6 +134,36 @@ function formatResult(result) {
125
134
  return `${lines.join("\n")}\n`;
126
135
  }
127
136
 
137
+ function listAsarFiles({ asar, contains }) {
138
+ if (!asar) throw new Error("--asar is required");
139
+ const archive = readAsar(asar);
140
+ const files = walkFiles(archive.header)
141
+ .map(([file]) => file)
142
+ .filter((file) => contains == null || file.includes(contains));
143
+ return { asar, files };
144
+ }
145
+
146
+ function readAsarFile({ asar, file }) {
147
+ if (!asar) throw new Error("--asar is required");
148
+ if (!file) throw new Error("--file is required");
149
+ const archive = readAsar(asar);
150
+ const node = new Map(walkFiles(archive.header)).get(file);
151
+ if (!node) throw new Error(`Could not find ${file} in ${asar}`);
152
+ if (node.unpacked) throw new Error(`Cannot read unpacked ASAR file ${file} from ${asar}`);
153
+ const size = Number(node.size || 0);
154
+ const start = archive.dataStart + Number(node.offset || 0);
155
+ const content = archive.buffer.subarray(start, start + size).toString("utf8");
156
+ return { asar, file, size, content };
157
+ }
158
+
159
+ function formatAsarListResult(result) {
160
+ return result.files.length > 0 ? `${result.files.join("\n")}\n` : "";
161
+ }
162
+
163
+ function formatAsarCatResult(result) {
164
+ return result.content;
165
+ }
166
+
128
167
  function formatError(error, { debug = false } = {}) {
129
168
  if (debug || process.env.CODEX_PLUS_PATCHER_DEBUG === "1") return error.stack || error.message || String(error);
130
169
  return `Error: ${error.message || String(error)}`;
@@ -183,6 +222,16 @@ async function main() {
183
222
  printHelp();
184
223
  return;
185
224
  }
225
+ if (args.command === "asar-list") {
226
+ const result = listAsarFiles(args);
227
+ process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : formatAsarListResult(result));
228
+ return;
229
+ }
230
+ if (args.command === "asar-cat") {
231
+ const result = readAsarFile(args);
232
+ process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : formatAsarCatResult(result));
233
+ return;
234
+ }
186
235
  if (args.command !== "apply") throw new Error(`Unknown command: ${args.command}`);
187
236
 
188
237
  const patchSets = await loadPatchSets(args);
@@ -214,11 +263,15 @@ if (require.main === module) {
214
263
  module.exports = {
215
264
  createApplyProgress,
216
265
  expandPath,
266
+ formatAsarCatResult,
267
+ formatAsarListResult,
217
268
  formatError,
218
269
  formatResult,
219
270
  helpText,
271
+ listAsarFiles,
220
272
  loadPatchSets,
221
273
  parseArgs,
274
+ readAsarFile,
222
275
  requirePatchSetModule,
223
276
  shouldShowApplyProgress,
224
277
  };
package/src/core/asar.js CHANGED
@@ -26,6 +26,26 @@ function walkFiles(node, prefix = "", out = []) {
26
26
  return out;
27
27
  }
28
28
 
29
+ function ensureFileEntry(header, filePath) {
30
+ const parts = filePath.split("/").filter(Boolean);
31
+ if (parts.length === 0) throw new Error("Cannot add an empty ASAR path");
32
+
33
+ let node = header;
34
+ for (const part of parts.slice(0, -1)) {
35
+ node.files ||= {};
36
+ node.files[part] ||= { files: {} };
37
+ if (!node.files[part].files) throw new Error(`Cannot add ${filePath}: ${part} is already a file`);
38
+ node = node.files[part];
39
+ }
40
+
41
+ node.files ||= {};
42
+ const fileName = parts.at(-1);
43
+ const existing = node.files[fileName];
44
+ if (existing?.files) throw new Error(`Cannot add ${filePath}: path is already a directory`);
45
+ node.files[fileName] = existing || {};
46
+ return node.files[fileName];
47
+ }
48
+
29
49
  function fileIntegrity(buffer) {
30
50
  const blockSize = 4 * 1024 * 1024;
31
51
  const blocks = [];
@@ -42,6 +62,9 @@ function fileIntegrity(buffer) {
42
62
 
43
63
  function patchAsar(asarPath, fileTransforms, transformContext = {}) {
44
64
  const archive = readAsar(asarPath);
65
+ const assetFiles = transformContext.assetFiles || [];
66
+ for (const [filePath] of assetFiles) ensureFileEntry(archive.header, filePath);
67
+
45
68
  const entries = walkFiles(archive.header);
46
69
  const contents = new Map();
47
70
 
@@ -58,6 +81,11 @@ function patchAsar(asarPath, fileTransforms, transformContext = {}) {
58
81
  contents.set(filePath, Buffer.from(patched, "utf8"));
59
82
  }
60
83
 
84
+ for (const [filePath, content] of assetFiles) {
85
+ if (!contents.has(filePath)) throw new Error(`Could not add ${filePath} to app.asar`);
86
+ contents.set(filePath, Buffer.isBuffer(content) ? content : Buffer.from(String(content), "utf8"));
87
+ }
88
+
61
89
  let dataOffset = 0;
62
90
  const dataBuffers = [];
63
91
  for (const [filePath, node] of entries) {
@@ -82,6 +110,7 @@ function patchAsar(asarPath, fileTransforms, transformContext = {}) {
82
110
  }
83
111
 
84
112
  module.exports = {
113
+ ensureFileEntry,
85
114
  patchAsar,
86
115
  readAsar,
87
116
  sha256,
@@ -89,6 +89,13 @@ function collectFileTransforms(patchSet) {
89
89
  return collectPatchQueue(patchSet).flatMap((patch) => patch.fileTransforms || []);
90
90
  }
91
91
 
92
+ function collectAssetFiles(patchSet) {
93
+ return [
94
+ ...(patchSet.assetFiles || []),
95
+ ...collectPatchQueue(patchSet).flatMap((patch) => patch.assetFiles || []),
96
+ ];
97
+ }
98
+
92
99
  function collectInfoPlistStrings(patchSet) {
93
100
  return Object.assign(
94
101
  {},
@@ -139,6 +146,7 @@ async function applyPatchSet({
139
146
  const setPlistBuddyStringValue = operations.setPlistBuddyValue || setPlistBuddyValue;
140
147
  const patchQueue = collectPatchQueue(patchSet);
141
148
  const fileTransforms = collectFileTransforms(patchSet);
149
+ const assetFiles = collectAssetFiles(patchSet);
142
150
  if (dryRun) {
143
151
  return {
144
152
  sourceApp,
@@ -146,6 +154,7 @@ async function applyPatchSet({
146
154
  patchSet: patchSet.id,
147
155
  patches: patchQueue.map((patch) => patch.id),
148
156
  patchedFiles: fileTransforms.map(([filePath]) => filePath),
157
+ addedFiles: assetFiles.map(([filePath]) => filePath),
149
158
  dryRun: true,
150
159
  };
151
160
  }
@@ -162,7 +171,7 @@ async function applyPatchSet({
162
171
  const targetAsar = path.join(targetApp, ASAR_PATH_IN_BUNDLE);
163
172
  const patchContext = buildPatchContext(patchSet, patchQueue, operations);
164
173
  const patchedAsarSha = await withProgress(progress, progressOffset + 3, progressTotal, "Patch app.asar", () =>
165
- patchAsarFile(targetAsar, fileTransforms, patchContext),
174
+ patchAsarFile(targetAsar, fileTransforms, { ...patchContext, assetFiles }),
166
175
  );
167
176
 
168
177
  const plistPath = path.join(targetApp, "Contents/Info.plist");
@@ -185,6 +194,7 @@ async function applyPatchSet({
185
194
  patchSet: patchSet.id,
186
195
  patches: patchQueue.map((patch) => patch.id),
187
196
  patchedFiles: fileTransforms.map(([filePath]) => filePath),
197
+ addedFiles: assetFiles.map(([filePath]) => filePath),
188
198
  patchedAsarSha,
189
199
  dryRun: false,
190
200
  };
@@ -210,6 +220,7 @@ module.exports = {
210
220
  ASAR_PATH_IN_BUNDLE,
211
221
  applyPatchSet,
212
222
  collectFileTransforms,
223
+ collectAssetFiles,
213
224
  collectInfoPlistStrings,
214
225
  collectPatchQueue,
215
226
  getPatcherGitSha,