diff --git a/CHANGELOG.md b/CHANGELOG.md index f545558..c5bae8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,21 @@ and this project follows semantic versioning for tagged releases. - npm package metadata and public install documentation for the published `codex-profile` package. +- Experimental `app-instance` command for launching profile-specific Codex + Desktop app clones with isolated `CODEX_HOME`, Electron user data, and + profile-local instance logs. +- `logs --instance` for reading experimental app-instance logs. +- Branded README demo asset showing two scoped Codex Desktop profile instances + side by side. +- README launch-mode and isolation-boundary tables for the experimental + parallel Desktop workflow. +- README origin-story section explaining the real multi-account workflow that + motivated the project. + +### Changed + +- Refreshed README positioning around profile-scoped Codex Desktop instances + and included media assets in the npm package file list. ### Fixed @@ -20,6 +35,14 @@ and this project follows semantic versioning for tagged releases. ### Tests - Added npm package installation coverage. +- Added coverage for app-instance launch isolation, app clone rebuilds, and + completion/help output. + +### Fixed + +- Fixed experimental app-instance launches on macOS by preserving Codex's + `CFBundleName` for Electron helper lookup and launching cloned bundles + through `open -a` with workspace folders passed as documents. ## 0.2.0 - 2026-05-21 diff --git a/README.md b/README.md index df56ce2..86ed7a5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # codex-profiles +Two Codex profiles. One Mac. No token swapping. + [![CI](https://github.com/Ducksss/codex-profiles/actions/workflows/ci.yml/badge.svg)](https://github.com/Ducksss/codex-profiles/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/v/release/Ducksss/codex-profiles?sort=semver)](https://github.com/Ducksss/codex-profiles/releases) [![npm](https://img.shields.io/npm/v/codex-profile.svg)](https://www.npmjs.com/package/codex-profile) @@ -7,18 +9,22 @@ [![Shell: Bash](https://img.shields.io/badge/shell-bash-4EAA25.svg)](bin/codex-profile) [![Platform: macOS + Linux](https://img.shields.io/badge/platform-macOS%20%2B%20Linux-lightgrey.svg)](#platform-support) -Switch Codex CLI and Desktop accounts with isolated `CODEX_HOME` profiles -instead of copying `auth.json` token files around. +Switch Codex CLI and Desktop accounts with isolated `CODEX_HOME` profiles. +Keep personal, work, school, and client state separated without copying +`auth.json` token files around. `codex-profiles` is a small Bash wrapper around Codex's `CODEX_HOME` support. Each profile gets its own Codex home directory, so auth, settings, sessions, connectors, plugins, caches, logs, and local state stay separated while the wrapper launches Codex CLI or Codex Desktop with the selected profile. +![Two Codex Desktop profiles running side by side](media/codex-profile-parallel-instances.png) + ```sh codex-profile cli personal codex-profile cli work exec "review this repo" -codex-profile app edu +codex-profile app-instance personal ~/Dev/app-a +codex-profile app-instance work ~/Dev/app-b ``` ## Why It Exists @@ -37,6 +43,22 @@ connector state, plugins, caches, and logs shared. `codex-profile` gives the clean boundary a short command. +## The Workflow That Started It + +This started as an account-switching problem between profiles with different +strengths: + +- A school Codex account with higher limits for heavy coding sessions, but no + connector setup. +- A personal Codex account with medium limits, but the connector access needed + for email, outreach, and day-to-day automation workflows. + +Logging out, logging back in, reopening Desktop, and rebuilding context every +time was slow enough to break focus. Copying token files would have been the +wrong shortcut. The goal was a small command that keeps each account's Codex +state separate, then makes it possible to open the right profile for the job, +including two Desktop profiles side by side when the workflow calls for it. + ## Why Not Swap Auth Files? Auth-file switchers only move `auth.json`. That can change who Codex logs in as, @@ -54,9 +76,12 @@ codex-profile -> one CODEX_HOME per profile That makes it a better fit for work, personal, education, and client accounts where local Codex state should not bleed between contexts. -## Demo +## Desktop Demo -![codex-profiles promo frame](media/codex-profiles-saas-promo-frame.png) +The screenshot above shows the experimental Desktop flow: two Codex profiles +side by side, each with its own app clone, `CODEX_HOME`, Electron user data, +and profile-local desktop log. The settings/account panel is visible on purpose +so the profile boundary is easy to inspect. [Watch the short reveal video](media/codex-profiles-apple-reveal.mp4) @@ -64,6 +89,9 @@ where local Codex state should not bleed between contexts. - Isolated Codex homes per profile. - CLI and Codex Desktop launch support. +- Experimental parallel Codex Desktop app instances for power users on macOS. +- Profile-specific app clones with distinct macOS bundle identifiers. +- Separate Electron user data for each experimental Desktop instance. - No token copying, parsing, printing, or migration. - Read-only `list`, `status`, and `doctor` commands for diagnostics. - JSON output for automation. @@ -141,6 +169,21 @@ codex-profile app personal ~/Dev/my-project codex-profile app work ``` +Run an experimental parallel Codex Desktop instance with its own app clone and +Electron user data directory: + +```sh +codex-profile app-instance personal ~/Dev/project-a +codex-profile app-instance work --rebuild ~/Dev/project-b +``` + +Desktop launch modes are intentionally split: + +| Command | Use when | Behavior | +| --- | --- | --- | +| `codex-profile app ` | You want the normal Desktop app on one active profile. | Quits the canonical `Codex.app`, then relaunches it with the selected `CODEX_HOME`. | +| `codex-profile app-instance ` | You want multiple Desktop profiles open side by side. | Creates or reuses a profile-specific app clone, separate Electron user data, and a profile-local instance log. | + Check what exists and what is logged in: ```sh @@ -231,6 +274,39 @@ codex-profile logs personal codex-profile logs personal --tail 100 ``` +Experimental instance logs use their own file: + +```sh +codex-profile logs personal --instance --path +codex-profile logs personal --instance --tail 100 +``` + +### Run Parallel Desktop Instances + +`app-instance` is the visual power-user workflow: two Codex Desktop profiles, +same macOS user, separate Codex state. + +```sh +codex-profile app-instance personal ~/Dev/personal-app +codex-profile app-instance work ~/Dev/work-app +``` + +The command creates or reuses profile-specific app clones under +`~/Library/Application Support/codex-profile/app-instances`, patches each clone +with a distinct bundle identifier, re-signs it, and launches it without +quitting existing Codex windows. + +The separate command name is deliberate. `codex-profile app` remains the +predictable single-app switcher for existing workflows and scripts. +`codex-profile app-instance` is the explicit contract for cloned bundles, +parallel windows, and experimental Desktop behavior. + +If Codex Desktop updates or a clone looks stale: + +```sh +codex-profile app-instance work --rebuild ~/Dev/work-app +``` + ### Clone Safe Config Copy known non-secret config files from one profile to another: @@ -336,6 +412,7 @@ alias codex-app-work='codex-profile app work' ```text codex-profile app [workspace] +codex-profile app-instance [--rebuild] [workspace] codex-profile cli [codex-args...] codex-profile login [codex-login-args...] codex-profile init @@ -343,7 +420,7 @@ codex-profile remove [--yes] codex-profile status [profile] codex-profile status --json [profile] codex-profile path -codex-profile logs [--path|--tail [lines]] +codex-profile logs [--instance] [--path|--tail [lines]] codex-profile clone-config [--force] codex-profile list codex-profile doctor [--json] @@ -355,15 +432,16 @@ codex-profile --version ## Environment Overrides -```text -CODEX_APP Override Codex.app path -CODEX_APP_BIN Override Codex Desktop binary path -CODEX_CLI Override Codex CLI binary path -CODEX_PROFILE_UPGRADE_REPO Override upgrade repository -CODEX_PROFILE_UPGRADE_REF Override upgrade git ref -CODEX_PROFILE_UPGRADE_CACHE Override upgrade cache checkout -CODEX_PROFILE_UPGRADE_PREFIX Override upgrade install prefix -``` +| Variable | Purpose | +| --- | --- | +| `CODEX_APP` | Override the `Codex.app` path. | +| `CODEX_APP_BIN` | Override the Codex Desktop binary path. | +| `CODEX_CLI` | Override the Codex CLI binary path. | +| `CODEX_PROFILE_APP_INSTANCE_ROOT` | Override the experimental app-instance clone root. | +| `CODEX_PROFILE_UPGRADE_REPO` | Override the upgrade repository. | +| `CODEX_PROFILE_UPGRADE_REF` | Override the upgrade git ref. | +| `CODEX_PROFILE_UPGRADE_CACHE` | Override the upgrade cache checkout. | +| `CODEX_PROFILE_UPGRADE_PREFIX` | Override the upgrade install prefix. | Examples: @@ -384,6 +462,17 @@ The `app` command is macOS-only because it launches `Codex.app` and uses macOS app-control tooling to quit the running desktop app before relaunching it with a different `CODEX_HOME`. +The experimental `app-instance` command is also macOS-oriented. It creates a +profile-specific copy of `Codex.app`, patches its display name and bundle +identifier when macOS tooling is available, re-signs the clone, and launches it +without quitting other Codex windows. + +Existing clones are checked before launch. If required metadata is missing, +malformed, stale, or was created by an older `codex-profile` version, +`app-instance` rebuilds the clone automatically. Use `--rebuild` after Codex +Desktop updates or whenever you want to force a fresh copy from the installed +`Codex.app`. + ## Desktop App Notes Codex Desktop should run one profile at a time. `codex-profile app ` @@ -394,6 +483,34 @@ selected `CODEX_HOME`. For predictable account switching, launch Codex Desktop through `codex-profile` instead of Dock or Spotlight. +`app` and `app-instance` stay separate by design. Launching two windows from +`app` would make existing scripts surprising and would hide the important +implementation detail that parallel mode clones and re-signs an app bundle. +The command names describe the contract: `app` switches the canonical app, +while `app-instance` launches a profile-specific Desktop clone. + +### Experimental Parallel Instances + +`codex-profile app-instance ` is an opt-in escape hatch for users who +need two Codex Desktop profiles open at once. It keeps the normal `app` command +conservative and instead launches a profile-specific app clone with: + +- `CODEX_HOME` set to the selected profile home. +- Electron `--user-data-dir` set to `/electron-user-data`. +- A distinct macOS bundle identifier derived from the raw profile name. +- Desktop logs written to `/logs/desktop-instance.log`. +- Instance logs available through `codex-profile logs --instance`. +- App clones stored under + `~/Library/Application Support/codex-profile/app-instances` by default. + +The isolation boundary is intentionally narrow and inspectable: + +| Isolated per profile | Still shared by the macOS user | +| --- | --- | +| Codex auth, config, sessions, plugins, caches, logs, and local Codex state under the selected `CODEX_HOME`. | SSH keys, GitHub CLI auth, cloud CLI auth, browser cookies, OS keychain items, npm state, git credentials, and other credentials outside `CODEX_HOME`. | +| Electron user data for the cloned Desktop app. | The same macOS account, filesystem permissions, network environment, Dock, login items, and system keychains. | +| Profile-specific app clone metadata and bundle identifier. | The installed source `Codex.app` bundle used as the clone template. | + ## Security Model `codex-profile` does one security-sensitive thing: it sets `CODEX_HOME` before @@ -407,6 +524,10 @@ default repository is this project. `--dry-run` prints the source ref, cache path, and install prefix before anything changes. Do not point upgrade at a repository you do not trust. +`app-instance` adds Desktop app clone metadata and Electron user-data isolation, +but it is still profile-level process isolation. It is not a VM, container, or +separate macOS account. + Separate Codex homes are cleaner than swapping `auth.json`, but they are not full OS-level isolation. Your operating system user still shares SSH keys, GitHub CLI auth, browser cookies, cloud CLI credentials, npm state, and other @@ -441,8 +562,11 @@ are a cleaner boundary. ### Can I run two desktop profiles at once? -Not safely. Codex Desktop is treated as one active profile at a time. The `app` -command quits the current Codex app before launching the selected profile. +The default `app` command intentionally treats Codex Desktop as one active +profile at a time. For an opt-in experimental path, use +`codex-profile app-instance `. It launches a profile-specific app clone +with separate `CODEX_HOME` and Electron user data, but it does not isolate +external OS-level credentials. ### Does this isolate external tools too? @@ -466,7 +590,8 @@ make lint The test suite covers Bash syntax, profile path mapping, install smoke tests, CLI/login pass-through, list/version output, npm package installation, source upgrades, fresh-profile status checks, hardened status discovery, private -desktop log placement, and missing-CLI doctor output. +desktop log placement, app-instance clone metadata validation, parallel +Desktop launch coverage, and missing-CLI doctor output. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 4a005b3..c680cd6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,6 +28,11 @@ codes, connector credentials, or private logs. `codex-profiles` does not read or copy Codex auth tokens. It only sets `CODEX_HOME` before launching Codex. +The experimental `app-instance` command also creates profile-specific Codex app +clones and launches them with separate Electron user data directories. That is +profile-level process isolation for Codex Desktop state, not OS-level +isolation. + It does not isolate non-Codex credentials such as SSH keys, GitHub CLI auth, cloud CLI credentials, browser cookies, or OS keychain items. Use separate OS users for stronger isolation. diff --git a/bin/codex-profile b/bin/codex-profile index f3783e5..dffc788 100755 --- a/bin/codex-profile +++ b/bin/codex-profile @@ -9,6 +9,7 @@ CODEX_APP_BIN="${CODEX_APP_BIN:-$CODEX_APP/Contents/MacOS/Codex}" CODEX_BUNDLED_CLI="${CODEX_BUNDLED_CLI:-$CODEX_APP/Contents/Resources/codex}" CODEX_PROFILE_QUIT_ATTEMPTS="${CODEX_PROFILE_QUIT_ATTEMPTS:-30}" CODEX_PROFILE_QUIT_SLEEP="${CODEX_PROFILE_QUIT_SLEEP:-0.5}" +CODEX_PROFILE_APP_INSTANCE_ROOT="${CODEX_PROFILE_APP_INSTANCE_ROOT:-$HOME/Library/Application Support/codex-profile/app-instances}" CODEX_PROFILE_UPGRADE_REPO="${CODEX_PROFILE_UPGRADE_REPO:-https://github.com/Ducksss/codex-profiles.git}" CODEX_PROFILE_UPGRADE_REF="${CODEX_PROFILE_UPGRADE_REF:-main}" @@ -18,6 +19,7 @@ $PROGRAM - run Codex with isolated CODEX_HOME profiles Usage: $PROGRAM app [workspace] + $PROGRAM app-instance [--rebuild] [workspace] $PROGRAM cli [codex-args...] $PROGRAM login [codex-login-args...] $PROGRAM init @@ -25,7 +27,7 @@ Usage: $PROGRAM status [profile] $PROGRAM status --json [profile] $PROGRAM path - $PROGRAM logs [--path|--tail [lines]] + $PROGRAM logs [--instance] [--path|--tail [lines]] $PROGRAM clone-config [--force] $PROGRAM list $PROGRAM doctor [--json] @@ -37,6 +39,7 @@ Examples: $PROGRAM login personal $PROGRAM init work $PROGRAM app personal ~/Dev/my-project + $PROGRAM app-instance work --rebuild ~/Dev/client-project $PROGRAM cli personal exec "review this repo" $PROGRAM status $PROGRAM logs personal --tail 50 @@ -51,6 +54,7 @@ Environment: CODEX_APP Override Codex.app path. CODEX_APP_BIN Override Codex Desktop binary path. CODEX_CLI Override Codex CLI binary path. + CODEX_PROFILE_APP_INSTANCE_ROOT Override experimental app-instance clone root. CODEX_PROFILE_UPGRADE_REPO Override upgrade repository. CODEX_PROFILE_UPGRADE_REF Override upgrade git ref. CODEX_PROFILE_UPGRADE_CACHE Override upgrade cache checkout. @@ -242,6 +246,204 @@ command_app() { fi } +bundle_identifier_profile_suffix() { + local profile="$1" + local encoded + + encoded="$(printf '%s' "$profile" | LC_ALL=C od -An -tx1 -v | tr -d '[:space:]')" || die "Cannot encode profile name for app instance bundle identifier." + printf 'p%s\n' "$encoded" +} + +app_instance_bundle_identifier_for_profile() { + local profile="$1" + local bundle_suffix + + bundle_suffix="$(bundle_identifier_profile_suffix "$profile")" + printf 'com.openai.codex.profile.%s\n' "$bundle_suffix" +} + +app_instance_dir_for_profile() { + local profile="$1" + + validate_profile "$profile" + printf '%s/%s\n' "$CODEX_PROFILE_APP_INSTANCE_ROOT" "$profile" +} + +app_instance_app_for_profile() { + local profile="$1" + local instance_dir + + instance_dir="$(app_instance_dir_for_profile "$profile")" + printf '%s/Codex %s.app\n' "$instance_dir" "$profile" +} + +patch_app_instance_metadata() { + local profile="$1" + local app="$2" + local plist="$app/Contents/Info.plist" + local bundle_id display_name + + [[ -f "$plist" ]] || die "Codex app clone is missing Info.plist: $plist" + + bundle_id="$(app_instance_bundle_identifier_for_profile "$profile")" + display_name="Codex $profile" + + if ! command -v plutil > /dev/null 2>&1; then + note "Warning: plutil not found; app instance metadata was not patched." + return 0 + fi + + plutil -replace CFBundleIdentifier -string "$bundle_id" "$plist" > /dev/null || die "Cannot patch app instance bundle identifier." + plutil -replace CFBundleDisplayName -string "$display_name" "$plist" > /dev/null || die "Cannot patch app instance display name." + + if command -v codesign > /dev/null 2>&1; then + codesign --force --deep --sign - "$app" > /dev/null 2>&1 || die "Cannot re-sign app instance: $app" + else + note "Warning: codesign not found; patched app instance may not launch on macOS." + fi +} + +app_instance_plist_value() { + local app="$1" + local key="$2" + local plist="$app/Contents/Info.plist" + + [[ -f "$plist" ]] || return 1 + command -v plutil > /dev/null 2>&1 || return 2 + plutil -extract "$key" raw -o - "$plist" 2> /dev/null +} + +app_instance_is_incompatible() { + local profile="$1" + local app="$2" + local plist="$app/Contents/Info.plist" + local bundle_id bundle_name display_name expected_bundle_id expected_display_name + + [[ -f "$plist" ]] || return 0 + command -v plutil > /dev/null 2>&1 || return 1 + + bundle_name="$(app_instance_plist_value "$app" CFBundleName)" || return 0 + [[ "$bundle_name" == "Codex" ]] || return 0 + + expected_bundle_id="$(app_instance_bundle_identifier_for_profile "$profile")" + bundle_id="$(app_instance_plist_value "$app" CFBundleIdentifier)" || return 0 + [[ "$bundle_id" == "$expected_bundle_id" ]] || return 0 + + expected_display_name="Codex $profile" + display_name="$(app_instance_plist_value "$app" CFBundleDisplayName)" || return 0 + [[ "$display_name" == "$expected_display_name" ]] || return 0 + + return 1 +} + +create_app_instance_bundle() { + local profile="$1" + local instance_dir instance_app tmp_app source_bin + + [[ -d "$CODEX_APP" ]] || die "Codex.app not found at $CODEX_APP" + source_bin="$CODEX_APP/Contents/MacOS/Codex" + [[ -x "$source_bin" ]] || die "Codex Desktop binary not found at $source_bin" + + ensure_home "$CODEX_PROFILE_APP_INSTANCE_ROOT" + instance_dir="$(app_instance_dir_for_profile "$profile")" + ensure_home "$instance_dir" + instance_app="$(app_instance_app_for_profile "$profile")" + tmp_app="$instance_dir/.Codex $profile.app.tmp.$$" + + rm -rf -- "$tmp_app" + cp -R "$CODEX_APP" "$tmp_app" || die "Cannot copy Codex.app to app instance: $tmp_app" + patch_app_instance_metadata "$profile" "$tmp_app" + + if [[ -e "$instance_app" ]]; then + rm -rf -- "$instance_app" || die "Cannot replace app instance: $instance_app" + fi + + mv "$tmp_app" "$instance_app" || die "Cannot install app instance: $instance_app" +} + +ensure_app_instance_bundle() { + local profile="$1" + local rebuild="$2" + local instance_app instance_bin + + instance_app="$(app_instance_app_for_profile "$profile")" + instance_bin="$instance_app/Contents/MacOS/Codex" + + if [[ "$rebuild" == "yes" ]]; then + note "Rebuilding app instance for $profile" + create_app_instance_bundle "$profile" + return 0 + fi + + if [[ -x "$instance_bin" ]] && app_instance_is_incompatible "$profile" "$instance_app"; then + note "Rebuilding app instance for $profile because existing clone is incompatible" + create_app_instance_bundle "$profile" + return 0 + fi + + if [[ ! -x "$instance_bin" ]]; then + note "Creating app instance for $profile" + create_app_instance_bundle "$profile" + fi +} + +command_app_instance() { + local profile="${1:-}" + [[ -n "$profile" ]] || die "Usage: $PROGRAM app-instance [--rebuild] [workspace]" + shift || true + + local rebuild=no workspace="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --rebuild) + rebuild=yes + ;; + --) + shift + [[ "$#" -le 1 ]] || die "Usage: $PROGRAM app-instance [--rebuild] [workspace]" + workspace="${1:-}" + break + ;; + --*) + die "Usage: $PROGRAM app-instance [--rebuild] [workspace]" + ;; + *) + [[ -z "$workspace" ]] || die "Usage: $PROGRAM app-instance [--rebuild] [workspace]" + workspace="$1" + ;; + esac + shift + done + + workspace="${workspace:-$PWD}" + + local codex_home instance_app instance_bin user_data_dir log_dir log_file + codex_home="$(codex_home_for_profile "$profile")" + ensure_home "$codex_home" + ensure_app_instance_bundle "$profile" "$rebuild" + + instance_app="$(app_instance_app_for_profile "$profile")" + instance_bin="$instance_app/Contents/MacOS/Codex" + [[ -x "$instance_bin" ]] || die "Codex app instance binary not found at $instance_bin" + command -v open > /dev/null 2>&1 || die "macOS open command not found; app-instance is macOS-only." + + user_data_dir="$codex_home/electron-user-data" + ensure_home "$user_data_dir" + + log_dir="$codex_home/logs" + log_file="$log_dir/desktop-instance.log" + ensure_home "$log_dir" + (umask 077 && : > "$log_file") + chmod 600 "$log_file" 2> /dev/null || true + + note "Launching experimental Codex Desktop instance for $profile" + note "CODEX_HOME=$codex_home" + note "App bundle: $instance_app" + note "Electron user data: $user_data_dir" + note "Log: $log_file" + (umask 077 && open -n --env "CODEX_HOME=$codex_home" --stdout "$log_file" --stderr "$log_file" -a "$instance_app" "$workspace" --args "--user-data-dir=$user_data_dir") || die "Cannot launch app instance: $instance_app" +} + command_cli() { local profile="${1:-}" [[ -n "$profile" ]] || die "Usage: $PROGRAM cli [codex-args...]" @@ -566,14 +768,18 @@ command_path() { command_logs() { local profile="${1:-}" - [[ -n "$profile" ]] || die "Usage: $PROGRAM logs [--path|--tail [lines]]" + [[ -n "$profile" ]] || die "Usage: $PROGRAM logs [--instance] [--path|--tail [lines]]" shift || true local mode="cat" + local log_name="desktop.log" local lines=50 while [[ "$#" -gt 0 ]]; do case "$1" in + --instance) + log_name="desktop-instance.log" + ;; --path) mode="path" ;; @@ -588,7 +794,7 @@ command_logs() { die "Unknown logs option '$1'." ;; *) - die "Usage: $PROGRAM logs [--path|--tail [lines]]" + die "Usage: $PROGRAM logs [--instance] [--path|--tail [lines]]" ;; esac shift @@ -596,16 +802,22 @@ command_logs() { [[ "$lines" =~ ^[0-9]+$ ]] || die "Tail line count must be a non-negative integer." - local codex_home log_file + local codex_home log_file missing_label codex_home="$(codex_home_for_profile "$profile")" - log_file="$codex_home/logs/desktop.log" + log_file="$codex_home/logs/$log_name" + missing_label="$log_name" + if [[ "$log_name" == "desktop.log" ]]; then + missing_label="desktop log" + elif [[ "$log_name" == "desktop-instance.log" ]]; then + missing_label="desktop instance log" + fi if [[ "$mode" == "path" ]]; then printf '%s\n' "$log_file" return 0 fi - [[ -f "$log_file" ]] || die "No desktop log for $profile ($log_file)." + [[ -f "$log_file" ]] || die "No $missing_label for $profile ($log_file)." if [[ "$mode" == "tail" ]]; then tail -n "$lines" "$log_file" @@ -896,12 +1108,12 @@ _codex_profile() command="${COMP_WORDS[1]}" if [[ "$COMP_CWORD" -eq 1 ]]; then - COMPREPLY=( $(compgen -W "app cli login init remove status path logs clone-config list doctor completions upgrade version help" -- "$cur") ) + COMPREPLY=( $(compgen -W "app app-instance cli login init remove status path logs clone-config list doctor completions upgrade version help" -- "$cur") ) return 0 fi case "$command" in - app|cli|login|init|remove|status|path|logs) + app|app-instance|cli|login|init|remove|status|path|logs) if [[ "$COMP_CWORD" -eq 2 ]]; then profiles="$(codex-profile list 2>/dev/null)" COMPREPLY=( $(compgen -W "default personal work $profiles" -- "$cur") ) @@ -930,7 +1142,7 @@ EOF _codex_profile() { local -a commands profiles shells commands=( - app cli login init remove status path logs clone-config list doctor completions upgrade version help + app app-instance cli login init remove status path logs clone-config list doctor completions upgrade version help ) profiles=(${(f)"$(codex-profile list 2>/dev/null)"} default personal work) shells=(bash zsh fish) @@ -941,7 +1153,7 @@ _codex_profile() { fi case "$words[2]" in - app|cli|login|init|remove|status|path|logs) + app|app-instance|cli|login|init|remove|status|path|logs) if (( CURRENT == 3 )); then _describe 'profile' profiles fi @@ -965,8 +1177,8 @@ EOF fish) cat <<'EOF' complete -c codex-profile -f -complete -c codex-profile -n '__fish_is_first_arg' -a 'app cli login init remove status path logs clone-config list doctor completions upgrade version help' -complete -c codex-profile -n '__fish_seen_subcommand_from app cli login init remove status path logs' -a '(codex-profile list 2>/dev/null) default personal work' +complete -c codex-profile -n '__fish_is_first_arg' -a 'app app-instance cli login init remove status path logs clone-config list doctor completions upgrade version help' +complete -c codex-profile -n '__fish_seen_subcommand_from app app-instance cli login init remove status path logs' -a '(codex-profile list 2>/dev/null) default personal work' complete -c codex-profile -n '__fish_seen_subcommand_from clone-config' -a '(codex-profile list 2>/dev/null) default personal work' complete -c codex-profile -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish' EOF @@ -1053,6 +1265,9 @@ main() { app) command_app "$@" ;; + app-instance) + command_app_instance "$@" + ;; cli) command_cli "$@" ;; diff --git a/media/codex-profile-parallel-instances.png b/media/codex-profile-parallel-instances.png new file mode 100644 index 0000000..4973790 Binary files /dev/null and b/media/codex-profile-parallel-instances.png differ diff --git a/package.json b/package.json index 95950b2..291b038 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-profile", "version": "0.2.0", - "description": "Switch Codex CLI and Desktop accounts with isolated CODEX_HOME profiles.", + "description": "Switch Codex CLI/Desktop profiles and run experimental parallel Desktop instances.", "license": "MIT", "homepage": "https://github.com/Ducksss/codex-profiles#readme", "repository": { @@ -34,6 +34,7 @@ "bin/codex-profile", "CHANGELOG.md", "LICENSE", + "media", "README.md", "SECURITY.md" ], diff --git a/test/codex-profile-test.sh b/test/codex-profile-test.sh index b464873..b8966cb 100644 --- a/test/codex-profile-test.sh +++ b/test/codex-profile-test.sh @@ -428,6 +428,407 @@ FAKE_PKILL rm -rf "$tmp" } +write_fake_codex_app_bundle() { + local app="$1" + local message="$2" + + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + cat > "$app/Contents/Info.plist" <<'FAKE_PLIST' + + + + + CFBundleDisplayName + Codex + CFBundleExecutable + Codex + CFBundleIdentifier + com.openai.codex + CFBundleName + Codex + + +FAKE_PLIST + + cat > "$app/Contents/MacOS/Codex" <&2 + exit 64 +fi +printf 'MESSAGE=%s\n' "$message" +printf 'CODEX_HOME=%s\n' "\$CODEX_HOME" +printf 'ARGS=%s\n' "\$*" +FAKE_CODEX_APP + chmod 755 "$app/Contents/MacOS/Codex" +} + +write_fake_macos_bundle_tools() { + local fake_bin="$1" + + mkdir -p "$fake_bin" + cat > "$fake_bin/plutil" <<'FAKE_PLUTIL' +#!/usr/bin/env bash +if [[ "${1:-}" == "-extract" ]]; then + key="${2:-}" + plist="${!#}" + awk -v target="$key" ' + /.*<\/key>/ { + current = $0 + sub(/^.*/, "", current) + sub(/<\/key>.*$/, "", current) + waiting = current == target + next + } + waiting && // { + value = $0 + gsub(/^[[:space:]]*/, "", value) + gsub(/<\/string>[[:space:]]*$/, "", value) + print value + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$plist" + exit $? +fi + +if [[ "${1:-}" == "-replace" && "${3:-}" == "-string" ]]; then + key="$2" + value="$4" + plist="$5" + printf 'plutil %s\n' "$*" >> "${FAKE_TOOL_LOG:?}" + PLUTIL_KEY="$key" PLUTIL_VALUE="$value" perl -0pi -e ' + BEGIN { + $key = $ENV{"PLUTIL_KEY"}; + $value = $ENV{"PLUTIL_VALUE"}; + $value =~ s/&/&/g; + $value =~ s//>/g; + } + s#(\Q$key\E\s*)[^<]*()#$1$value$2#s; + ' "$plist" + exit $? +fi + +printf 'plutil %s\n' "$*" >> "${FAKE_TOOL_LOG:?}" +exit 0 +FAKE_PLUTIL + chmod 755 "$fake_bin/plutil" + + cat > "$fake_bin/codesign" <<'FAKE_CODESIGN' +#!/usr/bin/env bash +printf 'codesign %s\n' "$*" >> "${FAKE_TOOL_LOG:?}" +exit 0 +FAKE_CODESIGN + chmod 755 "$fake_bin/codesign" + + cat > "$fake_bin/osascript" <<'FAKE_OSASCRIPT' +#!/usr/bin/env bash +printf 'osascript should not be called\n' >&2 +exit 99 +FAKE_OSASCRIPT + chmod 755 "$fake_bin/osascript" + + cat > "$fake_bin/pgrep" <<'FAKE_PGREP' +#!/usr/bin/env bash +printf 'pgrep should not be called\n' >&2 +exit 99 +FAKE_PGREP + chmod 755 "$fake_bin/pgrep" + + cat > "$fake_bin/open" <<'FAKE_OPEN' +#!/usr/bin/env bash +stdout="/dev/null" +stderr="/dev/null" +app="" +env_args=() +file_args=() + +while [[ "$#" -gt 0 ]]; do + case "$1" in + -n|--new) + shift + ;; + --env) + env_args+=("$2") + shift 2 + ;; + --stdout) + stdout="$2" + shift 2 + ;; + --stderr) + stderr="$2" + shift 2 + ;; + -a) + app="$2" + shift 2 + ;; + --args) + shift + break + ;; + *) + file_args+=("$1") + shift + ;; + esac +done + +printf 'open -a %s files=%s args=%s\n' "$app" "${file_args[*]}" "$*" >> "${FAKE_TOOL_LOG:?}" + +if [[ "$stdout" == "$stderr" ]]; then + env OPEN_LAUNCHED=yes "${env_args[@]}" "$app/Contents/MacOS/Codex" "$@" > "$stdout" 2>&1 +else + env OPEN_LAUNCHED=yes "${env_args[@]}" "$app/Contents/MacOS/Codex" "$@" > "$stdout" 2> "$stderr" +fi +FAKE_OPEN + chmod 755 "$fake_bin/open" +} + +test_app_instance_launches_parallel_profile_without_quitting_existing_app() { + local tmp fake_app fake_bin tool_log instance_root instance_app log_file user_data_dir + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/personal/Codex personal.app" + log_file="$tmp/home/.codex-personal/logs/desktop-instance.log" + user_data_dir="$tmp/home/.codex-personal/electron-user-data" + write_fake_codex_app_bundle "$fake_app" "parallel launch" + write_fake_macos_bundle_tools "$fake_bin" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance personal "$tmp/workspace" + + assert_status 0 + assert_contains "Launching experimental Codex Desktop instance for personal" + assert_contains "App bundle: $instance_app" + assert_contains "Electron user data: $user_data_dir" + assert_contains "Log: $log_file" + [[ -x "$instance_app/Contents/MacOS/Codex" ]] || fail "app-instance did not create executable app clone" + [[ -d "$user_data_dir" ]] || fail "app-instance did not create isolated Electron user data directory" + [[ "$(mode_of "$tmp/home/.codex-personal")" == "700" ]] || fail "profile home is not private" + [[ "$(mode_of "$user_data_dir")" == "700" ]] || fail "Electron user data directory is not private" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "parallel launch" "$log_file" && break + sleep 0.1 + done + + [[ -f "$log_file" ]] || fail "desktop instance log was not created" + assert_not_contains "osascript should not be called" + assert_not_contains "pgrep should not be called" + grep -q "MESSAGE=parallel launch" "$log_file" || fail "app-instance did not launch cloned Codex app" + grep -q "CODEX_HOME=$tmp/home/.codex-personal" "$log_file" || fail "app-instance did not pass profile CODEX_HOME" + grep -Fqx "ARGS=--user-data-dir=$user_data_dir" "$log_file" || fail "app-instance passed document workspace as argv" + grep -q "CFBundleIdentifier" "$tool_log" || fail "app-instance did not patch bundle identifier" + grep -q "CFBundleDisplayName" "$tool_log" || fail "app-instance did not patch display name" + ! grep -q "CFBundleName" "$tool_log" || fail "app-instance patched CFBundleName and broke Electron helper lookup" + grep -q "codesign --force --deep --sign -" "$tool_log" || fail "app-instance did not re-sign patched bundle" + grep -q "open -a $instance_app files=$tmp/workspace args=--user-data-dir=$user_data_dir" "$tool_log" || fail "app-instance did not launch workspace through macOS open -a" + + rm -rf "$tmp" +} + +test_app_instance_reuses_compatible_existing_profile_app_clone() { + local tmp fake_app fake_bin tool_log instance_root instance_app log_file user_data_dir + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/personal/Codex personal.app" + log_file="$tmp/home/.codex-personal/logs/desktop-instance.log" + user_data_dir="$tmp/home/.codex-personal/electron-user-data" + write_fake_codex_app_bundle "$fake_app" "initial launch" + write_fake_macos_bundle_tools "$fake_bin" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance personal "$tmp/workspace-a" + + assert_status 0 + [[ -x "$instance_app/Contents/MacOS/Codex" ]] || fail "first app-instance launch did not create executable app clone" + + : > "$tool_log" + write_fake_codex_app_bundle "$fake_app" "source changed launch" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance personal "$tmp/workspace-b" + + assert_status 0 + assert_not_contains "Creating app instance for personal" + assert_not_contains "Rebuilding app instance for personal" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "initial launch" "$log_file" && break + sleep 0.1 + done + + grep -q "MESSAGE=initial launch" "$log_file" || fail "compatible existing app instance was not reused" + ! grep -q "MESSAGE=source changed launch" "$log_file" || fail "compatible existing app instance was rebuilt from source app" + grep -Fqx "ARGS=--user-data-dir=$user_data_dir" "$log_file" || fail "reused app instance did not keep isolated Electron user data" + ! grep -q "codesign" "$tool_log" || fail "compatible existing app instance was re-signed" + ! grep -q "CFBundleIdentifier" "$tool_log" || fail "compatible existing app instance metadata was patched" + + rm -rf "$tmp" +} + +test_app_instance_uses_unique_bundle_identifiers_for_similar_profile_names() { + local tmp fake_app fake_bin tool_log instance_root dot_plist underscore_plist + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + dot_plist="$instance_root/client.a/Codex client.a.app/Contents/Info.plist" + underscore_plist="$instance_root/client_a/Codex client_a.app/Contents/Info.plist" + write_fake_codex_app_bundle "$fake_app" "unique bundle id" + write_fake_macos_bundle_tools "$fake_bin" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance client.a "$tmp/workspace-a" + + assert_status 0 + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance client_a "$tmp/workspace-b" + + assert_status 0 + grep -q 'com.openai.codex.profile.p636c69656e742e61' "$dot_plist" || fail "dotted profile bundle identifier was not encoded uniquely" + grep -q 'com.openai.codex.profile.p636c69656e745f61' "$underscore_plist" || fail "underscored profile bundle identifier was not encoded uniquely" + ! cmp -s "$dot_plist" "$underscore_plist" || fail "distinct profile app metadata should not be identical" + + rm -rf "$tmp" +} + +test_app_instance_rebuild_replaces_existing_profile_app_clone() { + local tmp fake_app fake_bin tool_log instance_root instance_app stale_file log_file + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/work/Codex work.app" + stale_file="$instance_app/stale" + log_file="$tmp/home/.codex-work/logs/desktop-instance.log" + write_fake_codex_app_bundle "$fake_app" "rebuilt launch" + write_fake_macos_bundle_tools "$fake_bin" + mkdir -p "$instance_app" + printf 'stale\n' > "$stale_file" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance work --rebuild "$tmp/workspace" + + assert_status 0 + assert_contains "Rebuilding app instance for work" + [[ ! -e "$stale_file" ]] || fail "app-instance --rebuild did not remove stale app clone" + [[ -x "$instance_app/Contents/MacOS/Codex" ]] || fail "app-instance --rebuild did not recreate app clone" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "rebuilt launch" "$log_file" && break + sleep 0.1 + done + + grep -q "MESSAGE=rebuilt launch" "$log_file" || fail "rebuilt app instance did not launch" + + rm -rf "$tmp" +} + +test_app_instance_rebuilds_clone_with_missing_bundle_metadata() { + local tmp fake_app fake_bin tool_log instance_root instance_app log_file + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/personal/Codex personal.app" + log_file="$tmp/home/.codex-personal/logs/desktop-instance.log" + write_fake_codex_app_bundle "$fake_app" "fresh launch" + write_fake_macos_bundle_tools "$fake_bin" + write_fake_codex_app_bundle "$instance_app" "stale launch" + rm -f "$instance_app/Contents/Info.plist" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance personal "$tmp/workspace" + + assert_status 0 + assert_contains "Rebuilding app instance for personal because existing clone is incompatible" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "fresh launch" "$log_file" && break + sleep 0.1 + done + + [[ -f "$instance_app/Contents/Info.plist" ]] || fail "rebuilt app instance is missing Info.plist" + grep -q "MESSAGE=fresh launch" "$log_file" || fail "app instance with missing metadata was not rebuilt before launch" + ! grep -q "MESSAGE=stale launch" "$log_file" || fail "app instance with missing metadata launched without rebuild" + + rm -rf "$tmp" +} + +test_app_instance_rebuilds_clone_with_stale_bundle_identifier() { + local tmp fake_app fake_bin tool_log instance_root instance_app log_file plist + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/client.a/Codex client.a.app" + log_file="$tmp/home/.codex-client.a/logs/desktop-instance.log" + plist="$instance_app/Contents/Info.plist" + write_fake_codex_app_bundle "$fake_app" "fresh launch" + write_fake_macos_bundle_tools "$fake_bin" + write_fake_codex_app_bundle "$instance_app" "stale launch" + perl -0pi -e 's#(CFBundleIdentifier\s*)com\.openai\.codex()#$1com.openai.codex.profile.client-a$2#' "$plist" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance client.a "$tmp/workspace" + + assert_status 0 + assert_contains "Rebuilding app instance for client.a because existing clone is incompatible" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "fresh launch" "$log_file" && break + sleep 0.1 + done + + grep -q 'com.openai.codex.profile.p636c69656e742e61' "$plist" || fail "stale bundle identifier was not rebuilt to encoded identifier" + grep -q "MESSAGE=fresh launch" "$log_file" || fail "app instance with stale bundle identifier was not rebuilt before launch" + ! grep -q "MESSAGE=stale launch" "$log_file" || fail "app instance with stale bundle identifier launched without rebuild" + + rm -rf "$tmp" +} + +test_app_instance_rebuilds_clone_with_incompatible_bundle_name() { + local tmp fake_app fake_bin tool_log instance_root instance_app log_file + tmp="$(mktemp -d)" + fake_app="$tmp/Codex.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + instance_root="$tmp/instances" + instance_app="$instance_root/personal/Codex personal.app" + log_file="$tmp/home/.codex-personal/logs/desktop-instance.log" + write_fake_codex_app_bundle "$fake_app" "fresh launch" + write_fake_macos_bundle_tools "$fake_bin" + write_fake_codex_app_bundle "$instance_app" "stale launch" + perl -0pi -e 's#(CFBundleName\s*)Codex()#$1Codex personal$2#' "$instance_app/Contents/Info.plist" + + run_cmd env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" CODEX_APP="$fake_app" CODEX_PROFILE_APP_INSTANCE_ROOT="$instance_root" "$SCRIPT" app-instance personal "$tmp/workspace" + + assert_status 0 + assert_contains "Rebuilding app instance for personal because existing clone is incompatible" + + for _ in {1..20}; do + [[ -f "$log_file" ]] && grep -q "fresh launch" "$log_file" && break + sleep 0.1 + done + + grep -q "MESSAGE=fresh launch" "$log_file" || fail "incompatible app instance was not rebuilt before launch" + ! grep -q "MESSAGE=stale launch" "$log_file" || fail "incompatible app instance launched without rebuild" + + rm -rf "$tmp" +} + test_doctor_skips_status_when_cli_missing() { local tmp tmp="$(mktemp -d)" @@ -528,6 +929,26 @@ test_logs_prints_path_and_contents() { rm -rf "$tmp" } +test_logs_prints_instance_path_and_contents() { + local tmp log_file + tmp="$(mktemp -d)" + log_file="$tmp/home/.codex-personal/logs/desktop-instance.log" + mkdir -p "${log_file%/*}" + printf 'instance first\ninstance second\n' > "$log_file" + + run_cmd env HOME="$tmp/home" "$SCRIPT" logs personal --instance --path + + assert_status 0 + assert_equals "$log_file" + + run_cmd env HOME="$tmp/home" "$SCRIPT" logs personal --instance --tail 1 + + assert_status 0 + assert_equals "instance second" + + rm -rf "$tmp" +} + test_logs_reports_missing_log_file() { local tmp tmp="$(mktemp -d)" @@ -621,12 +1042,18 @@ test_doctor_json_reports_missing_cli_and_skips_status() { } test_completions_generate_shell_scripts() { + run_cmd "$SCRIPT" help + + assert_status 0 + assert_contains "app-instance" + run_cmd "$SCRIPT" completions bash assert_status 0 assert_contains "complete -F _codex_profile codex-profile" assert_contains "clone-config" assert_contains "upgrade" + assert_contains "app-instance" run_cmd "$SCRIPT" completions zsh @@ -634,6 +1061,7 @@ test_completions_generate_shell_scripts() { assert_contains "#compdef codex-profile" assert_contains "logs" assert_contains "upgrade" + assert_contains "app-instance" run_cmd "$SCRIPT" completions fish @@ -641,6 +1069,7 @@ test_completions_generate_shell_scripts() { assert_contains "complete -c codex-profile" assert_contains "remove" assert_contains "upgrade" + assert_contains "app-instance" } test_upgrade_dry_run_reports_plan_without_mutating_files() { @@ -920,12 +1349,20 @@ test_status_treats_not_logged_in_as_normal_status test_status_propagates_unexpected_cli_failure test_app_logs_stay_under_profile_home test_app_forces_quit_when_app_server_is_still_running +test_app_instance_launches_parallel_profile_without_quitting_existing_app +test_app_instance_reuses_compatible_existing_profile_app_clone +test_app_instance_uses_unique_bundle_identifiers_for_similar_profile_names +test_app_instance_rebuild_replaces_existing_profile_app_clone +test_app_instance_rebuilds_clone_with_missing_bundle_metadata +test_app_instance_rebuilds_clone_with_stale_bundle_identifier +test_app_instance_rebuilds_clone_with_incompatible_bundle_name test_doctor_skips_status_when_cli_missing test_init_creates_private_profile_home_without_codex test_remove_aborts_when_confirmation_does_not_match test_remove_yes_deletes_profile_home test_remove_yes_deletes_profiles_named_like_common_aliases test_logs_prints_path_and_contents +test_logs_prints_instance_path_and_contents test_logs_reports_missing_log_file test_status_json_reports_profiles_without_creating_missing_default test_status_json_treats_not_logged_in_as_normal_status