codex-copilot-dx 0.6.0 → 0.6.2

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.
@@ -0,0 +1,734 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { spawnSync } from "node:child_process";
6
+ import {
7
+ AUTH_PROFILE_CLAUDE,
8
+ readAuthProfileCredentials,
9
+ } from "./auth-profile.mjs";
10
+ import {
11
+ PM_STUDIO_2_9_7_RECIPE,
12
+ PM_STUDIO_RECIPES,
13
+ inspectElectronAsarIntegritySlots,
14
+ inspectAsarBuffer,
15
+ patchAsarBuffer,
16
+ sha256Hex,
17
+ } from "./pm-studio-asar.mjs";
18
+
19
+ export const DEFAULT_PM_STUDIO_APP_PATH = "/Applications/PM Studio.app";
20
+ export const PM_STUDIO_CLAUDE_AUTH_COMMAND = "ccdx auth login claude --reauth --github-login <personal-login>";
21
+ const MIN_FREE_MARGIN_BYTES = 16 * 1024 * 1024;
22
+
23
+ function setupError(code, message, details) {
24
+ const error = new Error(message);
25
+ error.code = code;
26
+ if (details !== undefined) error.details = details;
27
+ return error;
28
+ }
29
+
30
+ function defaultProcessRunner(command, args) {
31
+ return spawnSync(command, args, {
32
+ encoding: "utf8",
33
+ windowsHide: true,
34
+ maxBuffer: 8 * 1024 * 1024,
35
+ });
36
+ }
37
+
38
+ function normalizedProcessResult(result) {
39
+ if (typeof result === "string") return { status: 0, stdout: result, stderr: "" };
40
+ return {
41
+ status: result?.status ?? (result?.error ? 1 : 0),
42
+ stdout: String(result?.stdout || ""),
43
+ stderr: String(result?.stderr || ""),
44
+ error: result?.error,
45
+ };
46
+ }
47
+
48
+ function runChecked(processRunner, command, args, label) {
49
+ const result = normalizedProcessResult(processRunner(command, args));
50
+ if (result.error || result.status !== 0) {
51
+ throw setupError("PM_STUDIO_PROCESS_FAILED", `${label} failed`);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function plistRead(processRunner, plistPath, key) {
57
+ return runChecked(processRunner, "/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", plistPath],
58
+ `Reading ${path.basename(plistPath)}`).stdout.trim();
59
+ }
60
+
61
+ function defaultReadBundleMetadata({ infoPlistPath, processRunner }) {
62
+ return {
63
+ version: plistRead(processRunner, infoPlistPath, "CFBundleShortVersionString"),
64
+ build: plistRead(processRunner, infoPlistPath, "CFBundleVersion"),
65
+ bundleIdentifier: plistRead(processRunner, infoPlistPath, "CFBundleIdentifier"),
66
+ };
67
+ }
68
+
69
+ function defaultReadAsarIntegrity({ infoPlistPath, recipe, processRunner }) {
70
+ const key = recipe.integrityKey.split(".")[0];
71
+ const result = runChecked(processRunner, "/usr/bin/plutil", [
72
+ "-extract", key, "json", "-o", "-", infoPlistPath,
73
+ ], "Reading ElectronAsarIntegrity");
74
+ const dictionary = JSON.parse(result.stdout);
75
+ return dictionary["Resources/app.asar"] || null;
76
+ }
77
+
78
+ function defaultWriteAsarIntegrity({ infoPlistPath, recipe, hash, processRunner }) {
79
+ const key = recipe.integrityKey.split(".")[0];
80
+ const result = runChecked(processRunner, "/usr/bin/plutil", [
81
+ "-extract", key, "json", "-o", "-", infoPlistPath,
82
+ ], "Reading ElectronAsarIntegrity");
83
+ const dictionary = JSON.parse(result.stdout);
84
+ dictionary["Resources/app.asar"] = { algorithm: "SHA256", hash };
85
+ runChecked(processRunner, "/usr/bin/plutil", [
86
+ "-replace", key, "-json", JSON.stringify(dictionary), infoPlistPath,
87
+ ], "Updating ElectronAsarIntegrity hash");
88
+ }
89
+
90
+ function defaultInspectCodeSign({ appPath, processRunner }) {
91
+ const verify = normalizedProcessResult(processRunner("/usr/bin/codesign", [
92
+ "--verify", "--deep", "--strict", "--verbose=2", appPath,
93
+ ]));
94
+ if (verify.error || verify.status !== 0) return { valid: false, adHoc: false };
95
+ const display = normalizedProcessResult(processRunner("/usr/bin/codesign", [
96
+ "--display", "--verbose=4", appPath,
97
+ ]));
98
+ if (display.error || display.status !== 0) return { valid: false, adHoc: false };
99
+ const details = `${display.stdout}\n${display.stderr}`;
100
+ const entitlementsResult = normalizedProcessResult(processRunner("/usr/bin/codesign", [
101
+ "--display", "--entitlements", "-", "--xml", appPath,
102
+ ]));
103
+ if (entitlementsResult.error || entitlementsResult.status !== 0) return { valid: false, adHoc: false };
104
+ const entitlementOutput = `${entitlementsResult.stdout}\n${entitlementsResult.stderr}`;
105
+ const plistStart = entitlementOutput.indexOf("<?xml");
106
+ const plistEnd = entitlementOutput.lastIndexOf("</plist>");
107
+ if (plistStart < 0 || plistEnd < plistStart) return { valid: false, adHoc: false };
108
+ const entitlements = entitlementOutput.slice(plistStart, plistEnd + "</plist>".length);
109
+ return {
110
+ valid: true,
111
+ adHoc: /(?:^|\n)Signature=adhoc(?:\n|$)/i.test(details)
112
+ || /(?:^|\n)TeamIdentifier=not set(?:\n|$)/i.test(details),
113
+ identifier: details.match(/(?:^|\n)Identifier=([^\n]+)/)?.[1]?.trim() || "",
114
+ teamIdentifier: details.match(/(?:^|\n)TeamIdentifier=([^\n]+)/)?.[1]?.trim() || "",
115
+ flags: details.match(/\bflags=([^\s]+)/)?.[1] || "",
116
+ runtimeVersion: details.match(/(?:^|\n)Runtime Version=([^\n]+)/)?.[1]?.trim() || "",
117
+ entitlementsSha256: sha256Hex(entitlements),
118
+ };
119
+ }
120
+
121
+ function defaultInspectExecutableIntegrity({ appPath, infoPlistPath, recipe, processRunner }) {
122
+ const executableName = plistRead(processRunner, infoPlistPath, "CFBundleExecutable");
123
+ if (!executableName || path.basename(executableName) !== executableName) {
124
+ throw setupError("PM_STUDIO_EXECUTABLE_INVALID", "PM Studio CFBundleExecutable is invalid");
125
+ }
126
+ const executablePath = path.join(appPath, "Contents", "MacOS", executableName);
127
+ const frameworkPath = path.join(appPath, recipe.electronFrameworkPath);
128
+ const executableBytes = fs.readFileSync(executablePath);
129
+ const frameworkBytes = fs.readFileSync(frameworkPath);
130
+ const executableSlots = inspectElectronAsarIntegritySlots(executableBytes);
131
+ const frameworkSlots = inspectElectronAsarIntegritySlots(frameworkBytes);
132
+ return {
133
+ executableName,
134
+ executablePath,
135
+ executableSha256: sha256Hex(executableBytes),
136
+ frameworkPath,
137
+ frameworkSha256: sha256Hex(frameworkBytes),
138
+ embeddedIntegrity: {
139
+ state: executableSlots.slots.length === 0 && frameworkSlots.slots.length === 0 ? "absent" : "present",
140
+ supported: executableSlots.slots.length === 0 && frameworkSlots.slots.length === 0,
141
+ executable: executableSlots,
142
+ framework: frameworkSlots,
143
+ },
144
+ matchesRecipeName: executableName === recipe.executable,
145
+ };
146
+ }
147
+
148
+ function defaultSignApp({ appPath, processRunner }) {
149
+ runChecked(processRunner, "/usr/bin/codesign", [
150
+ "--force", "--deep", "--sign", "-", "--timestamp=none",
151
+ "--preserve-metadata=identifier,entitlements,flags,runtime", appPath,
152
+ ], "Ad-hoc codesigning PM Studio");
153
+ }
154
+
155
+ function defaultListBlockingProcesses({ processRunner }) {
156
+ const output = runChecked(processRunner, "/bin/ps", ["-axo", "pid=,command="],
157
+ "Checking PM Studio processes").stdout;
158
+ return output.split(/\r?\n/).filter((line) => {
159
+ const value = line.toLowerCase();
160
+ return value.includes("/pm studio.app/")
161
+ || value.includes("pm studio shipit")
162
+ || value.includes("pm studio updater")
163
+ || value.includes("com.pm-studio.app")
164
+ || value.includes("com.electron.pm-studio.shipit");
165
+ });
166
+ }
167
+
168
+ function defaultCopyBundle({ source, destination, processRunner }) {
169
+ if (fs.existsSync(destination)) throw setupError("PM_STUDIO_COPY_TARGET_EXISTS", `Copy target already exists: ${destination}`);
170
+ runChecked(processRunner, "/usr/bin/ditto", ["--rsrc", "--extattr", "--acl", source, destination],
171
+ "Copying PM Studio bundle");
172
+ }
173
+
174
+ const ATOMIC_REPLACE_JXA = `function run(argv) {
175
+ ObjC.import("Foundation");
176
+ const manager = $.NSFileManager.defaultManager;
177
+ const oldUrl = $.NSURL.fileURLWithPath(argv[0]);
178
+ const newUrl = $.NSURL.fileURLWithPath(argv[1]);
179
+ const result = Ref();
180
+ const error = Ref();
181
+ const ok = manager.replaceItemAtURLWithItemAtURLBackupItemNameOptionsResultingItemURLError(
182
+ oldUrl, newUrl, undefined, 1, result, error
183
+ );
184
+ if (!ok) throw new Error(ObjC.unwrap(error[0].localizedDescription));
185
+ }`;
186
+
187
+ function defaultReplaceApp({ appPath, stagePath, processRunner }) {
188
+ runChecked(processRunner, "/usr/bin/osascript", [
189
+ "-l", "JavaScript", "-e", ATOMIC_REPLACE_JXA, "--", appPath, stagePath,
190
+ ], "Atomically replacing PM Studio");
191
+ }
192
+
193
+ function directorySize(directory) {
194
+ let total = 0;
195
+ const visit = (current) => {
196
+ const stat = fs.lstatSync(current);
197
+ total += stat.size;
198
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
199
+ for (const name of fs.readdirSync(current)) visit(path.join(current, name));
200
+ };
201
+ visit(directory);
202
+ return total;
203
+ }
204
+
205
+ function nearestExistingPath(targetPath) {
206
+ let current = path.resolve(targetPath);
207
+ while (!fs.existsSync(current)) {
208
+ const parent = path.dirname(current);
209
+ if (parent === current) throw setupError("PM_STUDIO_PATH_UNAVAILABLE", `No existing parent for ${targetPath}`);
210
+ current = parent;
211
+ }
212
+ return current;
213
+ }
214
+
215
+ function defaultAvailableBytes(targetPath) {
216
+ const stat = fs.statfsSync(nearestExistingPath(targetPath), { bigint: true });
217
+ return Number(stat.bavail * stat.bsize);
218
+ }
219
+
220
+ function defaultVolumeId(targetPath) {
221
+ return String(fs.statSync(nearestExistingPath(targetPath)).dev);
222
+ }
223
+
224
+ function defaultAssertPermissions({ appPath, backupRoot }) {
225
+ try {
226
+ fs.accessSync(appPath, fs.constants.R_OK);
227
+ fs.accessSync(path.dirname(appPath), fs.constants.W_OK);
228
+ fs.accessSync(nearestExistingPath(backupRoot), fs.constants.W_OK);
229
+ } catch {
230
+ throw setupError("PM_STUDIO_PERMISSION_DENIED",
231
+ "PM Studio or its backup/staging location is not accessible to the current user; setup will not use sudo");
232
+ }
233
+ }
234
+
235
+ export function createPmStudioSetupOperations(overrides = {}) {
236
+ const processRunner = overrides.processRunner || defaultProcessRunner;
237
+ return {
238
+ processRunner,
239
+ readBundleMetadata: defaultReadBundleMetadata,
240
+ readAsarIntegrity: defaultReadAsarIntegrity,
241
+ writeAsarIntegrity: defaultWriteAsarIntegrity,
242
+ inspectCodeSign: defaultInspectCodeSign,
243
+ inspectExecutableIntegrity: defaultInspectExecutableIntegrity,
244
+ signApp: defaultSignApp,
245
+ listBlockingProcesses: defaultListBlockingProcesses,
246
+ copyBundle: defaultCopyBundle,
247
+ renamePath: ({ source, destination }) => fs.renameSync(source, destination),
248
+ replaceApp: defaultReplaceApp,
249
+ removePath: ({ target }) => fs.rmSync(target, { recursive: true, force: true }),
250
+ bundleSize: directorySize,
251
+ availableBytes: defaultAvailableBytes,
252
+ volumeId: defaultVolumeId,
253
+ assertPermissions: defaultAssertPermissions,
254
+ readClaudeCredentials: ({ home }) => readAuthProfileCredentials(AUTH_PROFILE_CLAUDE, { home }),
255
+ now: () => new Date(),
256
+ uuid: () => randomUUID(),
257
+ ...overrides,
258
+ processRunner,
259
+ };
260
+ }
261
+
262
+ function appPaths(appPath, recipe) {
263
+ return {
264
+ appPath,
265
+ infoPlistPath: path.join(appPath, recipe.infoPlistPath),
266
+ asarPath: path.join(appPath, recipe.asarPath),
267
+ };
268
+ }
269
+
270
+ function integrityMatches(actual, algorithm, hash) {
271
+ return actual?.algorithm === algorithm && actual?.hash === hash;
272
+ }
273
+
274
+ export function inspectPmStudioApp({
275
+ appPath,
276
+ recipe = PM_STUDIO_2_9_7_RECIPE,
277
+ operations = createPmStudioSetupOperations(),
278
+ } = {}) {
279
+ const paths = appPaths(appPath, recipe);
280
+ const metadata = operations.readBundleMetadata({ ...paths, recipe, processRunner: operations.processRunner });
281
+ const asar = inspectAsarBuffer(fs.readFileSync(paths.asarPath), recipe);
282
+ const plistIntegrity = operations.readAsarIntegrity({ ...paths, recipe, processRunner: operations.processRunner });
283
+ const executableIntegrity = operations.inspectExecutableIntegrity({
284
+ ...paths, recipe, processRunner: operations.processRunner,
285
+ });
286
+ const codeSign = operations.inspectCodeSign({ ...paths, recipe, processRunner: operations.processRunner });
287
+ const issues = [];
288
+
289
+ if (String(metadata.version) !== recipe.version) {
290
+ issues.push(`version is ${metadata.version}, expected ${recipe.version}`);
291
+ }
292
+ if (String(metadata.build) !== recipe.build) {
293
+ issues.push(`build is ${metadata.build}, expected ${recipe.build}`);
294
+ }
295
+ if (String(metadata.bundleIdentifier) !== recipe.bundleIdentifier) {
296
+ issues.push(`bundle identifier is ${metadata.bundleIdentifier}, expected ${recipe.bundleIdentifier}`);
297
+ }
298
+ if (!executableIntegrity?.matchesRecipeName) issues.push("CFBundleExecutable does not match the recipe");
299
+ if (!executableIntegrity?.embeddedIntegrity?.supported
300
+ || executableIntegrity.embeddedIntegrity.state !== recipe.embeddedAsarIntegrity) {
301
+ issues.push("Electron Framework has an unsupported embedded ASAR integrity slot");
302
+ }
303
+
304
+ const clean = issues.length === 0
305
+ && asar.state === "clean"
306
+ && integrityMatches(plistIntegrity, "SHA256", recipe.sourceHeaderSha256)
307
+ && executableIntegrity.executableSha256 === recipe.sourceExecutableSha256
308
+ && executableIntegrity.frameworkSha256 === recipe.sourceElectronFrameworkSha256
309
+ && codeSign?.valid === true
310
+ && codeSign?.adHoc === false
311
+ && codeSign?.teamIdentifier === recipe.sourceTeamIdentifier;
312
+ const patched = issues.length === 0
313
+ && asar.state === "patched"
314
+ && integrityMatches(plistIntegrity, "SHA256", recipe.patchedHeaderSha256)
315
+ && codeSign?.valid === true
316
+ && codeSign?.adHoc === true;
317
+
318
+ if (!clean && !patched) {
319
+ if (asar.state === "drift") issues.push("app.asar is unknown drift");
320
+ else if (asar.state === "clean"
321
+ && !integrityMatches(plistIntegrity, "SHA256", recipe.sourceHeaderSha256)) {
322
+ issues.push("clean ASAR does not match ElectronAsarIntegrity");
323
+ } else if (asar.state === "patched"
324
+ && !integrityMatches(plistIntegrity, "SHA256", recipe.patchedHeaderSha256)) {
325
+ issues.push("patched ASAR does not match ElectronAsarIntegrity");
326
+ }
327
+ if (!codeSign?.valid) issues.push("codesign verification failed");
328
+ else if (asar.state === "clean" && codeSign.adHoc) issues.push("clean bundle has an unexpected ad-hoc signature");
329
+ else if (asar.state === "patched" && !codeSign.adHoc) issues.push("patched bundle is not ad-hoc signed");
330
+ else if (asar.state === "clean" && codeSign.teamIdentifier !== recipe.sourceTeamIdentifier) {
331
+ issues.push(`clean bundle TeamIdentifier is ${codeSign.teamIdentifier || "missing"}, expected ${recipe.sourceTeamIdentifier}`);
332
+ }
333
+ if (asar.state === "clean"
334
+ && executableIntegrity.executableSha256 !== recipe.sourceExecutableSha256) {
335
+ issues.push("main executable hash does not match the clean recipe");
336
+ }
337
+ if (asar.state === "clean"
338
+ && executableIntegrity.frameworkSha256 !== recipe.sourceElectronFrameworkSha256) {
339
+ issues.push("Electron Framework hash does not match the clean recipe");
340
+ }
341
+ }
342
+
343
+ return {
344
+ state: clean ? "clean" : patched ? "patched" : "drift",
345
+ metadata: {
346
+ version: String(metadata.version),
347
+ build: String(metadata.build),
348
+ bundleIdentifier: String(metadata.bundleIdentifier),
349
+ },
350
+ asar,
351
+ plistIntegrity,
352
+ executableIntegrity,
353
+ codeSign,
354
+ paths,
355
+ issues,
356
+ };
357
+ }
358
+
359
+ function assertInstalledAppPath(appPath) {
360
+ const stat = fs.lstatSync(appPath);
361
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
362
+ throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
363
+ }
364
+ }
365
+
366
+ function assertNoBlockingProcesses(operations, appPath) {
367
+ const processes = operations.listBlockingProcesses({ appPath, processRunner: operations.processRunner });
368
+ if (!Array.isArray(processes)) throw setupError("PM_STUDIO_PROCESS_CHECK_FAILED", "PM Studio process check returned invalid data");
369
+ if (processes.length > 0) {
370
+ throw setupError("PM_STUDIO_RUNNING", `PM Studio or its updater is running (${processes.length} matching process(es)); quit it and retry`);
371
+ }
372
+ }
373
+
374
+ function assertValidClaudeProfile(operations, home) {
375
+ let credentials;
376
+ try {
377
+ credentials = operations.readClaudeCredentials({ home });
378
+ } catch {
379
+ throw setupError("PM_STUDIO_CLAUDE_AUTH_INVALID",
380
+ `Claude isolated authentication could not be read. Run \`${PM_STUDIO_CLAUDE_AUTH_COMMAND}\` first.`);
381
+ }
382
+ if (!credentials?.configured || !credentials?.valid) {
383
+ const reason = credentials?.reason ? ` (${credentials.reason})` : "";
384
+ throw setupError("PM_STUDIO_CLAUDE_AUTH_INVALID",
385
+ `Claude isolated authentication is not valid${reason}. Run \`${PM_STUDIO_CLAUDE_AUTH_COMMAND}\` first.`);
386
+ }
387
+ }
388
+
389
+ function selectRecipe(metadata, recipes) {
390
+ return recipes.find((recipe) => recipe.version === String(metadata.version)
391
+ && recipe.build === String(metadata.build)
392
+ && recipe.bundleIdentifier === String(metadata.bundleIdentifier));
393
+ }
394
+
395
+ function backupName(recipe) {
396
+ return `${recipe.id}-${recipe.sourceAsarSha256.slice(0, 12)}`;
397
+ }
398
+
399
+ function backupManifest(recipe, createdAt) {
400
+ return {
401
+ schema_version: 1,
402
+ kind: "ccdx-pm-studio-backup",
403
+ recipe_id: recipe.id,
404
+ created_at: createdAt.toISOString(),
405
+ app: {
406
+ name: "PM Studio",
407
+ bundle_identifier: recipe.bundleIdentifier,
408
+ version: recipe.version,
409
+ build: recipe.build,
410
+ },
411
+ source: {
412
+ asar_sha256: recipe.sourceAsarSha256,
413
+ asar_header_sha256: recipe.sourceHeaderSha256,
414
+ electron_asar_integrity: { algorithm: "SHA256", hash: recipe.sourceHeaderSha256 },
415
+ binaries: {
416
+ main_executable_sha256: recipe.sourceExecutableSha256,
417
+ electron_framework_sha256: recipe.sourceElectronFrameworkSha256,
418
+ embedded_asar_integrity: recipe.embeddedAsarIntegrity,
419
+ },
420
+ },
421
+ patched: {
422
+ asar_sha256: recipe.patchedAsarSha256,
423
+ asar_header_sha256: recipe.patchedHeaderSha256,
424
+ electron_asar_integrity: { algorithm: "SHA256", hash: recipe.patchedHeaderSha256 },
425
+ binaries: null,
426
+ },
427
+ backup_bundle: "PM Studio.app",
428
+ restore_rule: `Restore only while the installed app is PM Studio ${recipe.version} build ${recipe.build}.`,
429
+ };
430
+ }
431
+
432
+ function validateBackup({ backupDir, recipe, operations }) {
433
+ const manifestPath = path.join(backupDir, "manifest.json");
434
+ const backupAppPath = path.join(backupDir, "PM Studio.app");
435
+ let manifest;
436
+ try {
437
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
438
+ } catch {
439
+ throw setupError("PM_STUDIO_BACKUP_INVALID", `Existing PM Studio backup manifest is invalid: ${manifestPath}`);
440
+ }
441
+ if (manifest?.kind !== "ccdx-pm-studio-backup"
442
+ || manifest?.recipe_id !== recipe.id
443
+ || manifest?.app?.bundle_identifier !== recipe.bundleIdentifier
444
+ || manifest?.app?.version !== recipe.version
445
+ || manifest?.app?.build !== recipe.build
446
+ || manifest?.source?.asar_sha256 !== recipe.sourceAsarSha256
447
+ || manifest?.source?.asar_header_sha256 !== recipe.sourceHeaderSha256
448
+ || !integrityMatches(manifest?.source?.electron_asar_integrity, "SHA256", recipe.sourceHeaderSha256)
449
+ || manifest?.source?.binaries?.main_executable_sha256 !== recipe.sourceExecutableSha256
450
+ || manifest?.source?.binaries?.electron_framework_sha256 !== recipe.sourceElectronFrameworkSha256
451
+ || manifest?.source?.binaries?.embedded_asar_integrity !== recipe.embeddedAsarIntegrity
452
+ || manifest?.patched?.asar_sha256 !== recipe.patchedAsarSha256
453
+ || manifest?.patched?.asar_header_sha256 !== recipe.patchedHeaderSha256
454
+ || !integrityMatches(manifest?.patched?.electron_asar_integrity, "SHA256", recipe.patchedHeaderSha256)) {
455
+ throw setupError("PM_STUDIO_BACKUP_INVALID", `Existing PM Studio backup does not match recipe ${recipe.id}`);
456
+ }
457
+ const inspection = inspectPmStudioApp({ appPath: backupAppPath, recipe, operations });
458
+ if (inspection.state !== "clean") {
459
+ throw setupError("PM_STUDIO_BACKUP_INVALID", "Existing PM Studio backup is not an exact clean bundle", inspection.issues);
460
+ }
461
+ return { backupDir, backupAppPath, manifestPath, manifest, reused: true };
462
+ }
463
+
464
+ function ensureBackup({ appPath, backupRoot, recipe, operations }) {
465
+ const finalDir = path.join(backupRoot, backupName(recipe));
466
+ if (fs.existsSync(finalDir)) return validateBackup({ backupDir: finalDir, recipe, operations });
467
+
468
+ fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
469
+ const tempDir = path.join(backupRoot, `.${backupName(recipe)}.${operations.uuid()}.tmp`);
470
+ const backupAppPath = path.join(tempDir, "PM Studio.app");
471
+ const manifestPath = path.join(tempDir, "manifest.json");
472
+ fs.mkdirSync(tempDir, { mode: 0o700 });
473
+ try {
474
+ operations.copyBundle({ source: appPath, destination: backupAppPath, processRunner: operations.processRunner });
475
+ fs.writeFileSync(manifestPath, `${JSON.stringify(backupManifest(recipe, operations.now()), null, 2)}\n`, { mode: 0o600 });
476
+ const inspection = inspectPmStudioApp({ appPath: backupAppPath, recipe, operations });
477
+ if (inspection.state !== "clean") {
478
+ throw setupError("PM_STUDIO_BACKUP_VERIFY_FAILED", "New PM Studio backup failed verification", inspection.issues);
479
+ }
480
+ try {
481
+ operations.renamePath({ source: tempDir, destination: finalDir });
482
+ } catch (error) {
483
+ if (!fs.existsSync(finalDir)) throw error;
484
+ return validateBackup({ backupDir: finalDir, recipe, operations });
485
+ }
486
+ return {
487
+ backupDir: finalDir,
488
+ backupAppPath: path.join(finalDir, "PM Studio.app"),
489
+ manifestPath: path.join(finalDir, "manifest.json"),
490
+ manifest: JSON.parse(fs.readFileSync(path.join(finalDir, "manifest.json"), "utf8")),
491
+ reused: false,
492
+ };
493
+ } finally {
494
+ if (fs.existsSync(tempDir)) operations.removePath({ target: tempDir });
495
+ }
496
+ }
497
+
498
+ function writePatchedBinaryRecord({ backup, inspection, operations }) {
499
+ const manifest = JSON.parse(fs.readFileSync(backup.manifestPath, "utf8"));
500
+ manifest.patched.binaries = {
501
+ main_executable_sha256: inspection.executableIntegrity.executableSha256,
502
+ electron_framework_sha256: inspection.executableIntegrity.frameworkSha256,
503
+ };
504
+ manifest.patched.signing_metadata = signingMetadata(inspection.codeSign);
505
+ const temporaryPath = `${backup.manifestPath}.${operations.uuid()}.tmp`;
506
+ try {
507
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
508
+ operations.renamePath({ source: temporaryPath, destination: backup.manifestPath });
509
+ } finally {
510
+ if (fs.existsSync(temporaryPath)) operations.removePath({ target: temporaryPath });
511
+ }
512
+ backup.manifest = manifest;
513
+ }
514
+
515
+ function assertPatchedBinaryRecord({ inspection, manifestPath }) {
516
+ let manifest;
517
+ try {
518
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
519
+ } catch {
520
+ throw setupError("PM_STUDIO_PATCH_RECORD_INVALID", "PM Studio patched-binary record is missing or invalid");
521
+ }
522
+ const binaries = manifest?.patched?.binaries;
523
+ if (!binaries
524
+ || binaries.main_executable_sha256 !== inspection.executableIntegrity.executableSha256
525
+ || binaries.electron_framework_sha256 !== inspection.executableIntegrity.frameworkSha256) {
526
+ throw setupError("PM_STUDIO_BUNDLE_DRIFT", "PM Studio executable or Electron Framework differs from the installed patch record");
527
+ }
528
+ if (JSON.stringify(manifest?.patched?.signing_metadata) !== JSON.stringify(signingMetadata(inspection.codeSign))) {
529
+ throw setupError("PM_STUDIO_BUNDLE_DRIFT", "PM Studio signing metadata differs from the installed patch record");
530
+ }
531
+ }
532
+
533
+ function signingMetadata(codeSign) {
534
+ return {
535
+ identifier: codeSign?.identifier || "",
536
+ flags: codeSign?.flags || "",
537
+ runtime_version: codeSign?.runtimeVersion || "",
538
+ entitlements_sha256: codeSign?.entitlementsSha256 || "",
539
+ };
540
+ }
541
+
542
+ function flagsWithoutAdHoc(value) {
543
+ const flags = String(value || "");
544
+ const match = flags.match(/^0x([0-9a-f]+)/i);
545
+ if (!match) return flags.replace(/\badhoc\b,?/gi, "").replace(/\(,/, "(");
546
+ return `0x${(BigInt(`0x${match[1]}`) & ~2n).toString(16)}`;
547
+ }
548
+
549
+ function assertSigningMetadataPreserved(source, staged) {
550
+ const before = signingMetadata(source.codeSign);
551
+ const after = signingMetadata(staged.codeSign);
552
+ before.flags = flagsWithoutAdHoc(before.flags);
553
+ after.flags = flagsWithoutAdHoc(after.flags);
554
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
555
+ throw setupError("PM_STUDIO_SIGNING_METADATA_CHANGED",
556
+ "Ad-hoc signing did not preserve PM Studio identifier, entitlements, flags, and runtime metadata");
557
+ }
558
+ }
559
+
560
+ function assertSpace({ appPath, backupRoot, backupExists, operations }) {
561
+ const bundleBytes = operations.bundleSize(appPath);
562
+ if (!Number.isFinite(bundleBytes) || bundleBytes <= 0) throw setupError("PM_STUDIO_SIZE_INVALID", "Could not determine PM Studio bundle size");
563
+ const appParent = path.dirname(appPath);
564
+ const stageRequired = bundleBytes + MIN_FREE_MARGIN_BYTES;
565
+ const backupRequired = backupExists ? 0 : bundleBytes + MIN_FREE_MARGIN_BYTES;
566
+ const sameVolume = operations.volumeId(appParent) === operations.volumeId(backupRoot);
567
+ if (sameVolume) {
568
+ if (operations.availableBytes(appParent) < stageRequired + backupRequired) {
569
+ throw setupError("PM_STUDIO_SPACE_INSUFFICIENT", "Not enough free space for PM Studio staging and backup");
570
+ }
571
+ } else {
572
+ if (operations.availableBytes(appParent) < stageRequired
573
+ || (!backupExists && operations.availableBytes(backupRoot) < backupRequired)) {
574
+ throw setupError("PM_STUDIO_SPACE_INSUFFICIENT", "Not enough free space for PM Studio staging or backup");
575
+ }
576
+ }
577
+ }
578
+
579
+ function replaceVerifiedStage({ appPath, stagePath, operations }) {
580
+ try {
581
+ operations.replaceApp({ appPath, stagePath, processRunner: operations.processRunner });
582
+ return null;
583
+ } catch (error) {
584
+ return error;
585
+ }
586
+ }
587
+
588
+ function recoveryMessage(backup) {
589
+ return `Verified backup: ${backup.backupAppPath}; manifest: ${backup.manifestPath}. Quit PM Studio, confirm the installed version/build still match, then restore only this version-matched backup or reinstall the official App.`;
590
+ }
591
+
592
+ export async function runPmStudioSetup({
593
+ commandName = "ccdx",
594
+ appPath = DEFAULT_PM_STUDIO_APP_PATH,
595
+ home = os.homedir(),
596
+ backupRoot = path.join(home, ".local", "share", "codex-copilot-dx", "pm-studio-backups"),
597
+ recipes = PM_STUDIO_RECIPES,
598
+ operations: operationOverrides = {},
599
+ logger = (line) => console.log(line),
600
+ } = {}) {
601
+ const operations = createPmStudioSetupOperations(operationOverrides);
602
+ const messages = [];
603
+ const emit = (message) => {
604
+ messages.push(message);
605
+ logger(message);
606
+ };
607
+
608
+ assertValidClaudeProfile(operations, home);
609
+ assertInstalledAppPath(appPath);
610
+ operations.assertPermissions({ appPath, backupRoot });
611
+ assertNoBlockingProcesses(operations, appPath);
612
+
613
+ const genericPaths = {
614
+ appPath,
615
+ infoPlistPath: path.join(appPath, "Contents/Info.plist"),
616
+ processRunner: operations.processRunner,
617
+ };
618
+ const metadata = operations.readBundleMetadata(genericPaths);
619
+ const recipe = selectRecipe(metadata, recipes);
620
+ if (!recipe) {
621
+ throw setupError("PM_STUDIO_UNSUPPORTED_VERSION",
622
+ `PM Studio ${metadata.version} build ${metadata.build} is not supported; no files were changed`);
623
+ }
624
+
625
+ const source = inspectPmStudioApp({ appPath, recipe, operations });
626
+ const finalBackupDir = path.join(backupRoot, backupName(recipe));
627
+ if (source.state === "patched") {
628
+ if (!fs.existsSync(finalBackupDir)) {
629
+ throw setupError("PM_STUDIO_BACKUP_INVALID", "Patched PM Studio has no matching verified backup; no files were changed");
630
+ }
631
+ const backup = validateBackup({ backupDir: finalBackupDir, recipe, operations });
632
+ assertPatchedBinaryRecord({ inspection: source, manifestPath: backup.manifestPath });
633
+ emit(`[OK] PM Studio ${recipe.version} build ${recipe.build} is already patched; no backup or signing was repeated.`);
634
+ emit(`[OK] Verified backup: ${backup.backupAppPath}`);
635
+ emit(`[OK] Backup manifest: ${backup.manifestPath}`);
636
+ emit(`[INFO] Start ${commandName} before opening PM Studio.`);
637
+ return { status: "already_patched", changed: false, recipe: recipe.id, backup, inspection: source, messages };
638
+ }
639
+ if (source.state !== "clean") {
640
+ throw setupError("PM_STUDIO_BUNDLE_DRIFT",
641
+ `PM Studio ${recipe.version} build ${recipe.build} does not match the clean or patched recipe; no files were changed`, source.issues);
642
+ }
643
+
644
+ const backupExists = fs.existsSync(finalBackupDir);
645
+ assertSpace({ appPath, backupRoot, backupExists, operations });
646
+
647
+ emit("[WARN] PM Studio's vendor signature will be replaced with an ad-hoc signature; an official update or reinstall may overwrite this patch.");
648
+ const backup = ensureBackup({ appPath, backupRoot, recipe, operations });
649
+ const stagePath = path.join(path.dirname(appPath), `.${path.basename(appPath)}.ccdx-stage-${operations.uuid()}`);
650
+ let preserveStage = false;
651
+ try {
652
+ operations.copyBundle({ source: appPath, destination: stagePath, processRunner: operations.processRunner });
653
+ const stageAsarPath = path.join(stagePath, recipe.asarPath);
654
+ const patched = patchAsarBuffer(fs.readFileSync(stageAsarPath), recipe);
655
+ if (!patched.changed) throw setupError("PM_STUDIO_STAGE_INVALID", "Staging copy was unexpectedly already patched");
656
+ fs.writeFileSync(stageAsarPath, patched.buffer);
657
+ patched.buffer = null;
658
+ operations.writeAsarIntegrity({
659
+ ...appPaths(stagePath, recipe),
660
+ recipe,
661
+ hash: recipe.patchedHeaderSha256,
662
+ processRunner: operations.processRunner,
663
+ });
664
+ operations.signApp({ appPath: stagePath, recipe, processRunner: operations.processRunner });
665
+
666
+ const staged = inspectPmStudioApp({ appPath: stagePath, recipe, operations });
667
+ if (staged.state !== "patched") {
668
+ throw setupError("PM_STUDIO_STAGE_VERIFY_FAILED", "Patched PM Studio staging bundle failed verification", staged.issues);
669
+ }
670
+ assertSigningMetadataPreserved(source, staged);
671
+ writePatchedBinaryRecord({ backup, inspection: staged, operations });
672
+
673
+ assertNoBlockingProcesses(operations, appPath);
674
+ const unchangedSource = inspectPmStudioApp({ appPath, recipe, operations });
675
+ if (unchangedSource.state !== "clean"
676
+ || unchangedSource.asar.asarSha256 !== source.asar.asarSha256
677
+ || unchangedSource.asar.headerSha256 !== source.asar.headerSha256) {
678
+ throw setupError("PM_STUDIO_SOURCE_CHANGED", "PM Studio changed during setup; the verified staging bundle was not installed");
679
+ }
680
+
681
+ const replacementError = replaceVerifiedStage({ appPath, stagePath, operations });
682
+ let installed;
683
+ try {
684
+ installed = inspectPmStudioApp({ appPath, recipe, operations });
685
+ } catch {
686
+ preserveStage = fs.existsSync(stagePath);
687
+ throw setupError("PM_STUDIO_REPLACE_STATE_UNKNOWN",
688
+ `PM Studio replacement final state could not be verified. Do not launch it. ${recoveryMessage(backup)}${preserveStage ? ` Verified staging retained: ${stagePath}.` : ""}`);
689
+ }
690
+ if (installed.state !== "patched") {
691
+ preserveStage = installed.state !== "clean" && fs.existsSync(stagePath);
692
+ const code = installed.state === "clean"
693
+ ? "PM_STUDIO_REPLACE_FAILED"
694
+ : "PM_STUDIO_INSTALL_VERIFY_FAILED";
695
+ const stateMessage = installed.state === "clean"
696
+ ? "The installed PM Studio remains the verified clean bundle"
697
+ : "The installed PM Studio did not retain the verified patched state; do not launch it";
698
+ throw setupError(code,
699
+ `${stateMessage}. ${recoveryMessage(backup)}${preserveStage ? ` Verified staging retained: ${stagePath}.` : ""}`,
700
+ installed.issues);
701
+ }
702
+ try {
703
+ assertPatchedBinaryRecord({ inspection: installed, manifestPath: backup.manifestPath });
704
+ } catch (error) {
705
+ throw setupError("PM_STUDIO_INSTALL_VERIFY_FAILED",
706
+ `Installed PM Studio differs from the verified staging record; do not launch it. ${recoveryMessage(backup)}`,
707
+ [error.message]);
708
+ }
709
+ if (replacementError) {
710
+ emit("[WARN] Atomic replacement reported an error after the exact patched bundle became installed; the final bundle was independently verified.");
711
+ }
712
+
713
+ emit(`[OK] Patched PM Studio ${recipe.version} build ${recipe.build}.`);
714
+ emit(`[OK] app.asar SHA-256: ${installed.asar.asarSha256}`);
715
+ emit(`[OK] Verified backup: ${backup.backupAppPath}${backup.reused ? " (reused)" : ""}`);
716
+ emit(`[OK] Backup manifest: ${backup.manifestPath}`);
717
+ emit("[INFO] The verified staging bundle was installed with Foundation's atomic item replacement.");
718
+ emit(`[INFO] Restore step 1/3: quit PM Studio and its updater.`);
719
+ emit(`[INFO] Restore step 2/3: confirm the installed Info.plist still reports version/build ${recipe.version}/${recipe.build}; never restore this backup over another version.`);
720
+ emit(`[INFO] Restore step 3/3: move ${appPath} aside, copy ${backup.backupAppPath} to that exact path, and verify its signature before launch.`);
721
+ emit(`[INFO] Start ${commandName} before opening PM Studio.`);
722
+ return {
723
+ status: "patched",
724
+ changed: true,
725
+ recipe: recipe.id,
726
+ backup,
727
+ inspection: installed,
728
+ replacementMode: "foundation-atomic-replace",
729
+ messages,
730
+ };
731
+ } finally {
732
+ if (!preserveStage && fs.existsSync(stagePath)) operations.removePath({ target: stagePath });
733
+ }
734
+ }