feat(bar): one-flow install with quarantine automation and launch handoff

'ccs bar install' previously ended with two manual steps: clearing the
Gatekeeper quarantine by hand and running 'ccs bar' separately.

Install now detects an existing installation and says so before
reinstalling, clears the quarantine attribute itself via execFile with
a graceful fallback to the printed hint when xattr fails, and ends with
a TTY-aware 'Launch CCS Bar now?' prompt (default yes) that hands off
to the existing launch flow. --launch forces the handoff and
--no-launch suppresses it for scripted installs; non-TTY runs skip the
prompt and print the manual command instead.

Closes #1504
This commit is contained in:
Tam Nhu Tran
2026-06-10 13:25:07 -04:00
parent f743677d5d
commit 4eef3f77a4
4 changed files with 473 additions and 13 deletions
+12 -1
View File
@@ -31,16 +31,27 @@ ccs bar install
This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip. This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip.
If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall.
After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`. After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`.
After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely:
```bash
ccs bar install --launch # install and launch immediately
ccs bar install --no-launch # install, skip launch prompt
```
### Gatekeeper note ### Gatekeeper note
The v1 builds use ad-hoc signing, so the first launch may be blocked by Gatekeeper. Either right-click the app and choose Open, or clear the quarantine attribute: The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command:
```bash ```bash
xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app" xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app"
``` ```
Or right-click the app and choose Open.
## Launch ## Launch
```bash ```bash
+12 -2
View File
@@ -26,11 +26,20 @@ export async function showHelp(): Promise<void> {
['--version', 'Show CLI and installed app versions'], ['--version', 'Show CLI and installed app versions'],
], ],
], ],
[
'Install options:',
[
['--launch', 'Launch CCS Bar immediately after install without prompting'],
['--no-launch', 'Skip the launch prompt after install'],
],
],
[ [
'Examples:', 'Examples:',
[ [
['ccs bar', 'Start the web-server and open CCS Bar (default launch)'], ['ccs bar', 'Start the web-server and open CCS Bar (default launch)'],
['ccs bar install', 'Download and install CCS Bar into ~/Applications'], ['ccs bar install', 'Download and install CCS Bar, then prompt to launch'],
['ccs bar install --launch', 'Install and launch immediately (no prompt)'],
['ccs bar install --no-launch', 'Install without launching'],
['ccs bar version', 'Show CLI and installed app versions'], ['ccs bar version', 'Show CLI and installed app versions'],
['ccs bar uninstall', 'Remove CCS Bar and its version pin'], ['ccs bar uninstall', 'Remove CCS Bar and its version pin'],
], ],
@@ -49,8 +58,9 @@ export async function showHelp(): Promise<void> {
console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.')); console.log(dim(' macOS only. The app communicates with the CCS web-server on localhost only.'));
console.log( console.log(
dim( dim(
' Ad-hoc signed builds may require right-click > Open or `xattr -dr com.apple.quarantine` on first launch.' ' Gatekeeper quarantine is cleared automatically after install. If the app is still blocked,'
) )
); );
console.log(dim(' right-click > Open or run `xattr -dr com.apple.quarantine` manually.'));
console.log(''); console.log('');
} }
+121 -10
View File
@@ -19,6 +19,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { getCcsDir } from '../../config/config-loader-facade'; import { getCcsDir } from '../../config/config-loader-facade';
import { hasAnyFlag } from '../arg-extractor';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -84,6 +85,24 @@ export interface InstallDeps {
getCcsDir: () => string; getCcsDir: () => string;
/** Destination directory for the .app bundle (~/Applications by default). */ /** Destination directory for the .app bundle (~/Applications by default). */
getAppsDir: () => string; getAppsDir: () => string;
/**
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
* Run via `xattr -dr com.apple.quarantine <appPath>` (execFile, not shell-string).
* Returns true on success, false if xattr is unavailable or the call fails (non-fatal).
* Injectable for tests — avoids touching the real system.
*/
clearQuarantine: (appPath: string) => Promise<boolean>;
/**
* Invoke handleBarLaunch after a successful install when the user consents.
* Injectable so tests can assert invocation without starting a real server.
*/
launchBar: (args: string[]) => Promise<void>;
/**
* Ask the user whether to launch CCS Bar now (TTY-only).
* Returns true on yes/default, false on no or non-TTY.
* Injectable for tests.
*/
promptLaunch: () => Promise<boolean>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -341,6 +360,54 @@ function defaultGetAppsDir(): string {
return path.join(os.homedir(), 'Applications'); return path.join(os.homedir(), 'Applications');
} }
/**
* Clear the macOS Gatekeeper quarantine attribute from the installed app.
* Uses `xattr -dr com.apple.quarantine <appPath>` via execFile (not shell-string)
* so the path is passed as an argument, not interpolated into a shell command.
* Returns true on success, false on any error (non-fatal — install already succeeded).
*/
async function defaultClearQuarantine(appPath: string): Promise<boolean> {
try {
const { execFile } = await import('child_process');
const { promisify } = await import('util');
const execFileAsync = promisify(execFile);
await execFileAsync('xattr', ['-dr', 'com.apple.quarantine', appPath]);
return true;
} catch {
return false;
}
}
async function defaultLaunchBar(args: string[]): Promise<void> {
const { handleBarLaunch } = await import('./launch-subcommand');
await handleBarLaunch(args);
}
/**
* Ask the user whether to launch CCS Bar now.
* Only prompts when stdout is a TTY. Non-TTY: return false (caller prints guidance).
* Default answer is yes (Enter = launch).
*/
async function defaultPromptLaunch(): Promise<boolean> {
if (!process.stdout.isTTY) {
return false;
}
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
return new Promise((resolve) => {
rl.question('Launch CCS Bar now? [Y/n] ', (answer: string) => {
rl.close();
const normalized = answer.trim().toLowerCase();
// Empty (Enter) or 'y'/'yes' → launch
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
});
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Version pin helpers // Version pin helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -360,17 +427,35 @@ function pinBarVersion(ccsDir: string, version: string): void {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function handleBarInstall( export async function handleBarInstall(
_args: string[], args: string[],
deps: Partial<InstallDeps> = {} deps: Partial<InstallDeps> = {}
): Promise<void> { ): Promise<void> {
// Parse --launch / --no-launch flags before delegating to deps.
const forceLaunch = hasAnyFlag(args, ['--launch']);
const noLaunch = hasAnyFlag(args, ['--no-launch']);
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset; const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract; const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat; const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion; const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion;
const clearQuarantine = deps.clearQuarantine ?? defaultClearQuarantine;
const launchBar = deps.launchBar ?? defaultLaunchBar;
const promptLaunch = deps.promptLaunch ?? defaultPromptLaunch;
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)(); const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)(); const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
console.log('[i] Fetching CCS Bar release info...'); // 0. Already-installed detection: show the current version before proceeding.
const appPath = path.join(appsDir, BAR_APP_NAME);
if (fs.existsSync(appPath)) {
const existingVersion = readAppBundleVersion(appPath);
if (existingVersion !== null) {
console.log(`[i] CCS Bar v${existingVersion} is already installed. Reinstalling...`);
} else {
console.log('[i] CCS Bar is already installed. Reinstalling...');
}
} else {
console.log('[i] Fetching CCS Bar release info...');
}
// 1. Resolve the floating tag → download URL. // 1. Resolve the floating tag → download URL.
let releaseInfo: ReleaseAssetResult; let releaseInfo: ReleaseAssetResult;
@@ -397,7 +482,6 @@ export async function handleBarInstall(
// 3. Finding #12: assert that the expected .app bundle was actually extracted // 3. Finding #12: assert that the expected .app bundle was actually extracted
// before reporting success. // before reporting success.
const appPath = path.join(appsDir, BAR_APP_NAME);
if (!fs.existsSync(appPath)) { if (!fs.existsSync(appPath)) {
// Report what was actually extracted to help diagnose archive issues. // Report what was actually extracted to help diagnose archive issues.
let extracted: string[] = []; let extracted: string[] = [];
@@ -438,7 +522,20 @@ export async function handleBarInstall(
console.log('[!] Could not read app version from Info.plist.'); console.log('[!] Could not read app version from Info.plist.');
} }
// 5. Capability handshake via GET /api/bar/summary. // 5. Quarantine handling: run `xattr -dr com.apple.quarantine` automatically.
// This clears the Gatekeeper quarantine flag that ad-hoc builds receive on download.
// On success: print [OK] confirmation. On failure: fall back to printed guidance.
const quarantineCleared = await clearQuarantine(appPath);
if (quarantineCleared) {
console.log('[OK] Cleared Gatekeeper quarantine.');
} else {
console.log('[i] Gatekeeper note (ad-hoc build):');
console.log(' If macOS says the app is "damaged" or "unverified", run:');
console.log(` xattr -dr com.apple.quarantine "${appPath}"`);
console.log(' Or right-click the app and select Open.');
}
// 6. Capability handshake via GET /api/bar/summary.
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000. // Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
const barJsonPath = path.join(ccsDir, 'bar.json'); const barJsonPath = path.join(ccsDir, 'bar.json');
let baseUrl = 'http://127.0.0.1:3000'; let baseUrl = 'http://127.0.0.1:3000';
@@ -469,10 +566,24 @@ export async function handleBarInstall(
console.log('[i] Run `ccs bar` to start the server and recheck.'); console.log('[i] Run `ccs bar` to start the server and recheck.');
} }
// 6. Print Gatekeeper guidance for ad-hoc/unsigned builds. // 7. Launch handoff.
console.log(''); // --launch: skip prompt and launch immediately.
console.log('[i] Gatekeeper note (ad-hoc build):'); // --no-launch: skip prompt, print guidance.
console.log(' If macOS says the app is "damaged" or "unverified", run:'); // TTY: ask interactively (default yes).
console.log(` xattr -dr com.apple.quarantine "${path.join(appsDir, BAR_APP_NAME)}"`); // Non-TTY: print guidance, no prompt.
console.log(' Or right-click the app and select Open.'); if (forceLaunch) {
await launchBar([]);
} else if (noLaunch) {
console.log('[i] Run `ccs bar` to launch.');
} else {
const shouldLaunch = await promptLaunch();
if (shouldLaunch) {
await launchBar([]);
} else {
// Either user said no or non-TTY
if (!process.stdout.isTTY) {
console.log('[i] Run `ccs bar` to launch.');
}
}
}
} }
+328
View File
@@ -1894,6 +1894,334 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', ()
}); });
}); });
// ---------------------------------------------------------------------------
// GH-1504 — already-installed detection
// ---------------------------------------------------------------------------
describe('bar install: already-installed detection (GH-1504)', () => {
const FAKE_DOWNLOAD_URL =
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
it('shows existing version when app is already installed', async () => {
const appsDir = path.join(tempHome, 'Applications');
// Create a pre-existing CCS Bar.app with a known version
const existingAppPath = path.join(appsDir, 'CCS Bar.app');
fs.mkdirSync(path.join(existingAppPath, 'Contents'), { recursive: true });
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: async (_url: string, dest: string) => {
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
},
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: (appPath: string) => {
// Return version for pre-existing app (before download replaces it)
if (fs.existsSync(appPath)) return '1.0.0';
return null;
},
clearQuarantine: async () => true,
launchBar: async () => {
calls.push('launch');
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/already installed|Reinstall/i);
expect(allOutput).toMatch(/1\.0\.0/);
});
it('shows generic already-installed message when version is unreadable', async () => {
const appsDir = path.join(tempHome, 'Applications');
const existingAppPath = path.join(appsDir, 'CCS Bar.app');
fs.mkdirSync(existingAppPath, { recursive: true });
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: async (_url: string, dest: string) => {
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
},
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: () => null,
clearQuarantine: async () => true,
launchBar: async () => {
calls.push('launch');
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/already installed|Reinstall/i);
});
it('does not print already-installed message on fresh install', async () => {
const appsDir = path.join(tempHome, 'Applications');
// Do NOT pre-create the app
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: async (_url: string, dest: string) => {
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
},
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: () => '2.0.0',
clearQuarantine: async () => true,
launchBar: async () => {
calls.push('launch');
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
const allOutput = consoleOutput.join('\n');
expect(allOutput).not.toMatch(/already installed/i);
});
});
// ---------------------------------------------------------------------------
// GH-1504 — quarantine handling
// ---------------------------------------------------------------------------
describe('bar install: quarantine handling (GH-1504)', () => {
const FAKE_DOWNLOAD_URL =
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
function fakeExtract(appsDir: string) {
return async (_url: string, dest: string) => {
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
};
}
it('calls clearQuarantine with the correct app path after successful extraction', async () => {
const appsDir = path.join(tempHome, 'Applications');
const quarantineCalls: string[] = [];
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: fakeExtract(appsDir),
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: () => '1.0.0',
clearQuarantine: async (appPath: string) => {
quarantineCalls.push(appPath);
return true;
},
launchBar: async () => {
/* noop */
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
expect(quarantineCalls).toHaveLength(1);
expect(quarantineCalls[0]).toMatch(/CCS Bar\.app/);
expect(quarantineCalls[0]).toBe(path.join(appsDir, 'CCS Bar.app'));
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/\[OK\].*[Cc]leared.*[Qq]uarantine/);
});
it('quarantine failure is non-fatal: falls back to printed xattr hint', async () => {
const appsDir = path.join(tempHome, 'Applications');
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: fakeExtract(appsDir),
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: () => '1.0.0',
clearQuarantine: async () => false,
launchBar: async () => {
/* noop */
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
// Install still reports success
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
expect(allOutput).not.toMatch(/\[X\]/);
// Fallback xattr hint printed
expect(allOutput).toMatch(/xattr.*quarantine/i);
});
it('clearQuarantine is NOT called when extraction fails (app path absent)', async () => {
const appsDir = path.join(tempHome, 'Applications');
const quarantineCalls: string[] = [];
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
// Extraction does NOT place CCS Bar.app
downloadAndExtract: async (_url: string, dest: string) => {
fs.mkdirSync(dest, { recursive: true });
},
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
readAppBundleVersion: () => '1.0.0',
clearQuarantine: async (appPath: string) => {
quarantineCalls.push(appPath);
return true;
},
launchBar: async () => {
/* noop */
},
promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
});
// Should have returned early due to missing app
expect(quarantineCalls).toHaveLength(0);
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/\[X\]/);
});
});
// ---------------------------------------------------------------------------
// GH-1504 — launch flags and prompt behavior
// ---------------------------------------------------------------------------
describe('bar install: launch flags and prompt (GH-1504)', () => {
const FAKE_DOWNLOAD_URL =
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
function fakeExtract(appsDir: string) {
return async (_url: string, dest: string) => {
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
};
}
function baseDeps(appsDir: string, extra?: Partial<Record<string, unknown>>) {
return {
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
downloadAndExtract: fakeExtract(appsDir),
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
readAppBundleVersion: () => '1.0.0',
clearQuarantine: async () => true,
getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir,
...extra,
};
}
it('--no-launch skips prompt and does not invoke launchBar', async () => {
const appsDir = path.join(tempHome, 'Applications');
let launchCalled = false;
let promptCalled = false;
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall(['--no-launch'], {
...baseDeps(appsDir),
launchBar: async () => {
launchCalled = true;
},
promptLaunch: async () => {
promptCalled = true;
return false;
},
});
expect(launchCalled).toBe(false);
expect(promptCalled).toBe(false);
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/Run `ccs bar` to launch/i);
});
it('--launch invokes launchBar without prompting', async () => {
const appsDir = path.join(tempHome, 'Applications');
let launchCalled = false;
let promptCalled = false;
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall(['--launch'], {
...baseDeps(appsDir),
launchBar: async () => {
launchCalled = true;
},
promptLaunch: async () => {
promptCalled = true;
return false;
},
});
expect(launchCalled).toBe(true);
expect(promptCalled).toBe(false);
});
it('non-TTY skips prompt and prints launch guidance', async () => {
const appsDir = path.join(tempHome, 'Applications');
let launchCalled = false;
const { handleBarInstall } = await loadInstallSubcommand();
// Simulate non-TTY: promptLaunch returns false (mimics defaultPromptLaunch non-TTY path)
await handleBarInstall([], {
...baseDeps(appsDir),
launchBar: async () => {
launchCalled = true;
},
promptLaunch: async () => false,
});
expect(launchCalled).toBe(false);
});
it('user answers yes to prompt: launchBar is invoked', async () => {
const appsDir = path.join(tempHome, 'Applications');
let launchCalled = false;
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
...baseDeps(appsDir),
launchBar: async () => {
launchCalled = true;
},
promptLaunch: async () => true,
});
expect(launchCalled).toBe(true);
});
it('user answers no to prompt: launchBar not invoked', async () => {
const appsDir = path.join(tempHome, 'Applications');
let launchCalled = false;
const { handleBarInstall } = await loadInstallSubcommand();
await handleBarInstall([], {
...baseDeps(appsDir),
launchBar: async () => {
launchCalled = true;
},
promptLaunch: async () => false,
});
expect(launchCalled).toBe(false);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null // Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------