Track active tools and SDKs in activated.json

Store active tools and SDKs in a dedicated `activated.json` file when
they are activated by `emsdk activate`. Update `Tool.is_active()` to check
against `activated.json` rather than matching entries in `.emscripten`.

This solves several issues with the previous `.emscripten`-based
`is_active()` logic:

1. Tools with no `activated_cfg` (such as `ccache` or tools that only
   modify `PATH` or environment variables) previously returned
   `is_active() == False` because they had no `.emscripten` keys and no
   dependencies (`len(deps) == 0`).
2. Tools with dependencies but no `activated_cfg` previously falsely
   reported `is_active() == True` whenever their dependencies were active,
   even if the tool itself was never activated.

Replaces: #1345
diff --git a/.gitignore b/.gitignore
index ea08b2e..984aefc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
 *.pyc
 __pycache__
 
+# Track which tools are currently active
+/activated.json
+
 # Support for --embedded configs
 /.emscripten
 /.emscripten.old
diff --git a/emsdk.py b/emsdk.py
index bd98b8c..290a1da 100644
--- a/emsdk.py
+++ b/emsdk.py
@@ -1607,7 +1607,94 @@
 # Returns the absolute path to the file '.emscripten' for the current user on
 # this system.
 EM_CONFIG_PATH = os.path.join(EMSDK_PATH, ".emscripten")
-EM_CONFIG_DICT = {}
+ACTIVATED_JSON_PATH = os.path.join(EMSDK_PATH, "activated.json")
+ACTIVATED_TOOLS = set()
+
+
+def get_active_legacy():
+    """Legacy support for users without existing `activated.json` files.
+
+    Older versions of emsdk would imply active status purely based on the
+    emscripten config file.  This function uses that old method to generate
+    an initial `activated.json` state.
+    """
+    if not os.path.exists(EM_CONFIG_PATH):
+        return []
+
+    def load_em_config():
+        em_config_dict = {}
+        try:
+            lines = read_file(EM_CONFIG_PATH).splitlines()
+            for line in lines:
+                try:
+                    key, value = parse_key_value(line)
+                    if value:
+                        em_config_dict[key] = value
+                except Exception:
+                    pass
+        except Exception:
+            pass
+        return em_config_dict
+
+    em_config_dict = load_em_config()
+
+    def is_active_legacy(tool):
+        if not tool.is_installed():
+            return False
+
+        deps = tool.dependencies()
+        for dep in deps:
+            if not is_active_legacy(dep):
+                return False
+
+        activated_cfg = tool.activated_config()
+        if tool.legacy_cfg:
+            name, value = to_unix_path(tool.expand_vars(tool.legacy_cfg)).split('=')
+            activated_cfg[name] = value.strip("'")
+
+        if not activated_cfg:
+            return len(deps) > 0
+
+        for key, value in activated_cfg.items():
+            if key not in em_config_dict:
+                return False
+            config_value = em_config_dict[key].replace("emsdk_path + '", "'" + EMSDK_PATH).replace("$CFGDIR", EMSDK_PATH).strip("'")
+            if config_value != value:
+                return False
+
+        return True
+
+    active = []
+    for sdk in sdks:
+        if is_active_legacy(sdk):
+            active.append(sdk)
+    for tool in tools:
+        if is_active_legacy(tool):
+            active.append(tool)
+
+    return active
+
+
+def load_activated_json():
+    ACTIVATED_TOOLS.clear()
+    if not os.path.exists(ACTIVATED_JSON_PATH):
+        save_activated_json(get_active_legacy())
+        return
+
+    try:
+        data = json.loads(read_file(ACTIVATED_JSON_PATH))
+        assert isinstance(data, dict), f'Expected dict in {ACTIVATED_JSON_PATH}'
+        ACTIVATED_TOOLS.update(data.get('active_tools', []))
+    except Exception as e:
+        errlog(f'warning: failed to parse {ACTIVATED_JSON_PATH}: {e}')
+
+
+def save_activated_json(active_tools):
+    names = sorted(unique_items([str(t) for t in active_tools]))
+    data = {'active_tools': names}
+    write_file(ACTIVATED_JSON_PATH, json.dumps(data, indent=2) + '\n')
+    # Now re-load the newly generated file to update the `ACTIVATED_TOOLS` global state.
+    load_activated_json()
 
 
 def parse_key_value(line):
@@ -1622,22 +1709,6 @@
         return (key, '')
 
 
-def load_em_config():
-    EM_CONFIG_DICT.clear()
-    lines = []
-    try:
-        lines = read_file(EM_CONFIG_PATH).splitlines()
-    except Exception:
-        pass
-    for line in lines:
-        try:
-            key, value = parse_key_value(line)
-            if value:
-                EM_CONFIG_DICT[key] = value
-        except Exception:
-            pass
-
-
 def find_emscripten_root(active_tools):
     """Find the currently active emscripten root.
 
@@ -1830,6 +1901,7 @@
     cmake_build_type = None
     install_path = None
     activated_path_skip = False
+    legacy_cfg = None
     activated_cfg = None
     activated_env = None
     arch = None
@@ -2021,23 +2093,7 @@
             if not tool.is_active():
                 return False
 
-        activated_cfg = self.activated_config()
-        if not activated_cfg:
-            return len(deps) > 0
-
-        for key, value in activated_cfg.items():
-            if key not in EM_CONFIG_DICT:
-                debug_print(f'{self} is not active, because key="{key}" does not exist in .emscripten')
-                return False
-
-            # all paths are stored dynamically relative to the emsdk root, so
-            # normalize those first.
-            config_value = EM_CONFIG_DICT[key].replace("emsdk_path + '", "'" + EMSDK_PATH).replace("$CFGDIR", EMSDK_PATH)
-            config_value = config_value.strip("'")
-            if config_value != value:
-                debug_print(f'{self} is not active, because key="{key}" has value "{config_value}" but should have value "{value}"')
-                return False
-        return True
+        return str(self) in ACTIVATED_TOOLS
 
     def is_env_active(self):
         """Returns true if the system environment variables requires by this tool are currently active."""
@@ -2620,6 +2676,7 @@
         print('')
 
     generate_em_config(tools_to_activate, permanently_activate, system)
+    save_activated_json(tools_to_activate)
 
     # Construct a .bat or .ps1 script that will be invoked to set env. vars and PATH
     # We only do this on cmd or powershell since emsdk.bat/ps1 is able to modify the
@@ -3071,8 +3128,8 @@
         activating = cmd == 'activate'
         args = [expand_sdk_name(a, activating=activating) for a in args]
 
-    load_em_config()
     load_sdk_manifest()
+    load_activated_json()
 
     # Apply any overrides to git branch names to clone from.
     forked_url = extract_string_arg('--override-repository')
diff --git a/emsdk_manifest.json b/emsdk_manifest.json
index 46255d3..0b15c54 100644
--- a/emsdk_manifest.json
+++ b/emsdk_manifest.json
@@ -278,9 +278,9 @@
     "version": "4.2.0-rc3",
     "bitness": 64,
     "arch": "x86_64",
-    "activated_cfg": "EMSDK_CMAKE='%installation_dir%/bin/cmake%.exe%'",
+    "legacy_cfg": "EMSDK_CMAKE='%installation_dir%/bin/cmake%.exe%'",
     "activated_path": "%installation_dir%/bin",
-    "activated_cfg_macos": "EMSDK_CMAKE='%installation_dir%/CMake.app/Contents/bin/cmake%.exe%'",
+    "legacy_cfg_macos": "EMSDK_CMAKE='%installation_dir%/CMake.app/Contents/bin/cmake%.exe%'",
     "activated_path_macos": "%installation_dir%/CMake.app/Contents/bin",
     "url_windows": "https://github.com/Kitware/CMake/releases/download/v4.2.0-rc3/cmake-4.2.0-rc3-windows-x86_64.zip",
     "url_linux": "https://github.com/Kitware/CMake/releases/download/v4.2.0-rc3/cmake-4.2.0-rc3-linux-x86_64.tar.gz",
@@ -291,9 +291,9 @@
     "version": "4.2.0-rc3",
     "bitness": 64,
     "arch": "arm64",
-    "activated_cfg": "EMSDK_CMAKE='%installation_dir%/bin/cmake%.exe%'",
+    "legacy_cfg": "EMSDK_CMAKE='%installation_dir%/bin/cmake%.exe%'",
     "activated_path": "%installation_dir%/bin",
-    "activated_cfg_macos": "EMSDK_CMAKE='%installation_dir%/CMake.app/Contents/bin/cmake%.exe%'",
+    "legacy_cfg_macos": "EMSDK_CMAKE='%installation_dir%/CMake.app/Contents/bin/cmake%.exe%'",
     "activated_path_macos": "%installation_dir%/CMake.app/Contents/bin",
     "url_windows": "https://github.com/Kitware/CMake/releases/download/v4.2.0-rc3/cmake-4.2.0-rc3-windows-arm64.zip",
     "url_linux": "https://github.com/Kitware/CMake/releases/download/v4.2.0-rc3/cmake-4.2.0-rc3-linux-aarch64.tar.gz",
@@ -306,7 +306,7 @@
     "bitness": 64,
     "arch": "x86_64",
     "url_windows": "python-3.9.2-4-amd64+pywin32.zip",
-    "activated_cfg": "PYTHON='%installation_dir%/python.exe'",
+    "legacy_cfg": "PYTHON='%installation_dir%/python.exe'",
     "activated_env": "EMSDK_PYTHON=%installation_dir%/python.exe"
   },
   {
@@ -316,9 +316,9 @@
     "arch": "x86_64",
     "url_windows": "python-3.9.2-1-embed-amd64+pywin32.zip",
     "url_macos": "python-3.9.2-3-macos-x86_64.tar.gz",
-    "activated_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
+    "legacy_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
     "activated_env_windows": "EMSDK_PYTHON=%installation_dir%/python.exe",
-    "activated_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
+    "legacy_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
     "activated_env_macos": "EMSDK_PYTHON=%installation_dir%/bin/python3;SSL_CERT_FILE=%installation_dir%/lib/python3.9/site-packages/certifi/cacert.pem"
   },
   {
@@ -327,7 +327,7 @@
     "bitness": 64,
     "arch": "arm64",
     "url_macos": "python-3.9.2-1-macos-arm64.tar.gz",
-    "activated_cfg": "PYTHON='%installation_dir%/bin/python3'",
+    "legacy_cfg": "PYTHON='%installation_dir%/bin/python3'",
     "activated_env": "EMSDK_PYTHON=%installation_dir%/bin/python3;SSL_CERT_FILE=%installation_dir%/lib/python3.9/site-packages/certifi/cacert.pem"
   },
 
@@ -338,9 +338,9 @@
     "arch": "x86_64",
     "url_windows": "python-3.13.3-0-win-amd64.zip",
     "url_macos": "python-3.13.3-0-macos-x86_64.tar.gz",
-    "activated_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
+    "legacy_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
     "activated_env_windows": "EMSDK_PYTHON=%installation_dir%/python.exe",
-    "activated_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
+    "legacy_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
     "activated_env_macos": "EMSDK_PYTHON=%installation_dir%/bin/python3;SSL_CERT_FILE=%installation_dir%/lib/python3.13/site-packages/certifi/cacert.pem"
   },
   {
@@ -350,9 +350,9 @@
     "arch": "arm64",
     "url_windows": "python-3.13.3-0-win-arm64.zip",
     "url_macos": "python-3.13.3-0-macos-arm64.tar.gz",
-    "activated_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
+    "legacy_cfg_windows": "PYTHON='%installation_dir%/python.exe'",
     "activated_env_windows": "EMSDK_PYTHON=%installation_dir%/python.exe",
-    "activated_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
+    "legacy_cfg_macos": "PYTHON='%installation_dir%/bin/python3'",
     "activated_env_macos": "EMSDK_PYTHON=%installation_dir%/bin/python3;SSL_CERT_FILE=%installation_dir%/lib/python3.13/site-packages/certifi/cacert.pem"
   },
 
@@ -361,7 +361,7 @@
     "version": "68.12.0esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "is_old": true
@@ -371,7 +371,7 @@
     "version": "78.15.0esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "is_old": true
@@ -381,7 +381,7 @@
     "version": "91.13.0esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "is_old": true
@@ -391,7 +391,7 @@
     "version": "102.15.1esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "is_old": true
@@ -401,7 +401,7 @@
     "version": "115.28.0esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "is_old": true
@@ -411,7 +411,7 @@
     "version": "128.14.0esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox"
   },
@@ -420,7 +420,7 @@
     "version": "140.3.1esr",
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox"
   },
@@ -430,7 +430,7 @@
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
     "git_branch": "dummy field, to instruct emsdk to attempt to reinstall this tool even if it is installed, to check for new version",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "custom_is_installed_script": "is_firefox_installed"
@@ -441,7 +441,7 @@
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
     "git_branch": "dummy field, to instruct emsdk to attempt to reinstall this tool even if it is installed, to check for new version",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "custom_is_installed_script": "is_firefox_installed"
@@ -452,7 +452,7 @@
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
     "git_branch": "dummy field, to instruct emsdk to attempt to reinstall this tool even if it is installed, to check for new version",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "custom_is_installed_script": "is_firefox_installed"
@@ -463,7 +463,7 @@
     "bitness": 64,
     "url": "downloaded via mozdownload script, but a dummy directive is placed here so emsdk understands this Tool to be downloaded from the web",
     "git_branch": "dummy field, to instruct emsdk to attempt to reinstall this tool even if it is installed, to check for new version",
-    "activated_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
+    "legacy_cfg": "EMSDK_ACTIVATED_TEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "activated_env": "EMTEST_BROWSER=%actual_installation_dir%/%macos_app_bundle_prefix%firefox%.exe%",
     "custom_install_script": "download_firefox",
     "custom_is_installed_script": "is_firefox_installed"
@@ -596,7 +596,7 @@
     "version": "7.1.0",
     "bitness": 64,
     "url_windows": "mingw_7.1.0_64bit.zip",
-    "activated_cfg": "MINGW_ROOT='%installation_dir%'",
+    "legacy_cfg": "MINGW_ROOT='%installation_dir%'",
     "activated_path": "%installation_dir%/bin"
   },
   {
@@ -607,7 +607,7 @@
     "url_linux": "ninja-1.13.2-linux-x64.zip",
     "url_macos": "ninja-1.13.2-mac.zip",
     "arch": "x86_64",
-    "activated_cfg": "NINJA_ROOT='%installation_dir%'",
+    "legacy_cfg": "NINJA_ROOT='%installation_dir%'",
     "activated_path": "%installation_dir%"
   },
   {
@@ -618,7 +618,7 @@
     "url_linux": "ninja-1.13.2-linux-aarch64.zip",
     "url_macos": "ninja-1.13.2-mac.zip",
     "arch": "arm64",
-    "activated_cfg": "NINJA_ROOT='%installation_dir%'",
+    "legacy_cfg": "NINJA_ROOT='%installation_dir%'",
     "activated_path": "%installation_dir%"
   },
   {
@@ -627,7 +627,7 @@
     "bitness": 64,
     "url": "https://github.com/ninja-build/ninja.git",
     "git_branch": "release",
-    "activated_cfg": "NINJA=%installation_dir%/bin",
+    "legacy_cfg": "NINJA=%installation_dir%/bin",
     "activated_path": "%installation_dir%/bin",
     "cmake_build_type": "Release",
     "custom_install_script": "build_ninja"
diff --git a/test/test.py b/test/test.py
index 5b2d601..5dcacac 100755
--- a/test/test.py
+++ b/test/test.py
@@ -275,6 +275,29 @@
                 f.write(old_ver)
             run_emsdk('activate 6.0.3')
 
+    def test_activated_migration(self):
+        print('test migration from .emscripten to activated.json')
+        activated_json = os.path.abspath('activated.json')
+        if os.path.exists(activated_json):
+            os.remove(activated_json)
+
+        run_emsdk('install 6.0.3')
+        run_emsdk('activate 6.0.3')
+
+        # Remove activated.json to force migration from .emscripten
+        self.assertTrue(os.path.exists(activated_json))
+        os.remove(activated_json)
+
+        # Re-running emsdk list should populate activated.json from .emscripten
+        checked_call_with_output(emsdk + ' list', expected='INSTALLED')
+        self.assertTrue(os.path.exists(activated_json))
+        with open(activated_json) as f:
+            data = json.load(f)
+        self.assertIn('active_tools', data)
+        for t in data['active_tools']:
+            print('active:', t)
+        self.assertIn('node-24.19.0-64bit', data['active_tools'])
+
     def test_lib_building(self):
         print('building proper system libraries')
         do_lib_building(upstream_emcc())