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 0477fa9..02e94fd 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 10fc194..0e48b58 100644
--- a/emsdk.py
+++ b/emsdk.py
@@ -1597,7 +1597,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):
@@ -1612,22 +1699,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.
 
@@ -1820,6 +1891,7 @@
   cmake_build_type = None
   install_path = None
   activated_path_skip = False
+  legacy_cfg = None
   activated_cfg = None
   activated_env = None
   arch = None
@@ -2026,23 +2098,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."""
@@ -2623,6 +2679,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
@@ -3072,8 +3129,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 ef3f09b..3b88935 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())