diff --git a/apps/desktop/scripts/side-by-side-launchers.test.mjs b/apps/desktop/scripts/side-by-side-launchers.test.mjs index 68208bdb25..c3f19c0bf4 100644 --- a/apps/desktop/scripts/side-by-side-launchers.test.mjs +++ b/apps/desktop/scripts/side-by-side-launchers.test.mjs @@ -51,8 +51,10 @@ test('desktop payload exposes only identity-qualified launchers while preserving assert.equal(fs.readFileSync(path.join(payload, file), 'utf8'), `PE bytes for ${name}`) assert.ok(file.includes(cliName)) } - assert.equal(fs.existsSync(path.join(payload, 'bin/hermes.exe')), false) - assert.equal(fs.existsSync(path.join(payload, 'bin/hermes-acp.exe')), false) + // The agent's terminal runs the canonical names; they are never published as aliases. + for (const [name, file] of Object.entries(commands)) { + assert.equal(fs.readFileSync(path.join(payload, file), 'utf8'), `PE bytes for ${name}`) + } assert.deepEqual(stamps.stageDesktopLaunchers(payload, { cliName }), manifest) const xml = appExecutionAliasExtensions(manifest.launchers) assert.ok(xml.includes(`Alias="${cliName}.exe"`)) diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index fa111a75df..8339c7c441 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -6,7 +6,7 @@ * Provenance comes from CI, local git, or an explicit unknown-source stamp. */ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs" +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" import { resolve, join, relative, posix } from "path" import productIdentity from "../product-identity.cjs" import { channelBuildRequest } from "../../../scripts/msix-shared.mjs" @@ -115,7 +115,10 @@ export function isFallbackCommit(commit) { /** Qualify desktop CLI files before the immutable runtime paths are baked. * Canonical command keys remain stable for backend consumers; public aliases - * come from the declared filenames, never those internal keys. + * come from the declared filenames, never those internal keys. The canonical + * files stay beside them: the agent's own terminal puts /bin first on + * PATH (pm.environments.activate_dependencies) and runs bare `hermes`, while + * only the qualified names are published (MSIX aliases, ~/.local/bin links). */ export function stageDesktopLaunchers(root, identity = productIdentity) { const file = join(root, 'manifest.json') @@ -127,7 +130,7 @@ export function stageDesktopLaunchers(root, identity = productIdentity) { const alias = name.replace(/^hermes(?=-|$)/, identity.cliName) const destination = posix.join(posix.dirname(source), `${alias}${windows ? '.exe' : ''}`) if (source !== destination && existsSync(join(root, source))) { - renameSync(join(root, source), join(root, destination)) + copyFileSync(join(root, source), join(root, destination)) } if (!existsSync(join(root, destination))) throw new Error(`Missing desktop launcher: ${destination}`) commands[name] = destination diff --git a/hermes_cli/plugins_cmd_install.py b/hermes_cli/plugins_cmd_install.py index 663f08e62b..6c19648727 100644 --- a/hermes_cli/plugins_cmd_install.py +++ b/hermes_cli/plugins_cmd_install.py @@ -352,9 +352,12 @@ def _install_plugin_core( _refuse_unavailable_portable_plugin(plugin_name, tmp_target) if target.exists() and not force: + from pm.environments import cli_command_name + from pm.paths import repo_root + raise _pc().PluginOperationError( f"Plugin '{plugin_name}' already exists. Use force reinstall " - f"or run `hermes plugins update {plugin_name}`.") + f"or run `{cli_command_name(repo_root())} plugins update {plugin_name}`.") prior = old_metadata.get(plugin_name) if target.exists() and requested_revision is None and isinstance(prior, dict) and prior.get("pinned") is True: raise _pc().PluginOperationError( diff --git a/pm/environments.py b/pm/environments.py index 85ff480160..1a5588d873 100644 --- a/pm/environments.py +++ b/pm/environments.py @@ -91,20 +91,50 @@ def record_activation_inputs(stamps: Path, mtimes: dict[str, int], project_root: os.utime(stamp, ns=(mtime, mtime)) -def payload_venv(project_root: Path) -> Path | None: - """The environment a sealed payload ships beside its tree, or ``None``.""" - root = Path(project_root).resolve() +def _payload_manifest(root: Path) -> dict | None: + """The manifest of the sealed payload whose tree is the resolved *root*, or ``None``.""" manifest_path = root.parent / "manifest.json" if manifest_path.is_file(): manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) if (root.parent / manifest.get("repo", "")).resolve() == root: - venv = (root.parent / manifest["venv"]).resolve() - if not venv.is_relative_to(root.parent): - raise RuntimeError("payload environment escapes its root") - return venv + return manifest return None +def payload_venv(project_root: Path) -> Path | None: + """The environment a sealed payload ships beside its tree, or ``None``.""" + root = Path(project_root).resolve() + manifest = _payload_manifest(root) + if manifest is None: + return None + venv = (root.parent / manifest["venv"]).resolve() + if not venv.is_relative_to(root.parent): + raise RuntimeError("payload environment escapes its root") + return venv + + +def _payload_hermes_command(root: Path) -> Path | None: + manifest = _payload_manifest(root) + command = manifest.get("runtime", {}).get("commands", {}).get("hermes") if manifest else None + return root.parent / command if command else None + + +def payload_command_dir(project_root: Path) -> Path | None: + """The directory holding a sealed payload's own launchers (``/bin``), or ``None``.""" + command = _payload_hermes_command(Path(project_root).resolve()) + return command.parent if command else None + + +def cli_command_name(project_root: Path) -> str: + """The name a person types to run this install's CLI. + + A desktop channel build publishes its launcher under a qualified name + (``hermes-canary``) so it never shadows another install's ``hermes``. + """ + command = _payload_hermes_command(Path(project_root).resolve()) + return command.name.removesuffix(".exe") if command else "hermes" + + def base_venv(project_root: Path) -> Path: return payload_venv(project_root) or project_venv_dir(Path(project_root).resolve()) or Path(project_root).resolve() / "venv" @@ -115,14 +145,12 @@ def store_root(project_root: Path) -> Path: if override: return Path(override).resolve() root = Path(project_root).resolve() - manifest_path = root.parent / "manifest.json" - if manifest_path.is_file(): - manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) - if (root.parent / manifest.get("repo", "")).resolve() == root: - store = (root.parent / manifest["store"]).resolve() - if not store.is_relative_to(root.parent): - raise RuntimeError("payload store escapes its root") - return store + manifest = _payload_manifest(root) + if manifest is not None: + store = (root.parent / manifest["store"]).resolve() + if not store.is_relative_to(root.parent): + raise RuntimeError("payload store escapes its root") + return store from pm.paths import install_stamp_path for directory in (root, *root.parents): @@ -353,12 +381,16 @@ def activate_dependencies(project_root: Path) -> None: *[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]] os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)]) os.environ.pop("VIRTUAL_ENV", None) - executable_dir = venv_bin_dir(environment) # The venv's own `hermes`/`hermes-acp` console scripts are editable installs bound to # the build-time source snapshot, not this checkout (#124627): a child that resolves # `hermes` off PATH must hit the checkout's own launcher first, never the venv's copy. - prefix = [str(path) for path in (project_root.resolve() / ".hermes" / "bin", executable_dir) - if path.is_dir()] + # A sealed payload's launchers live in /bin instead. + directories = [payload_command_dir(project_root) or project_root.resolve() / ".hermes" / "bin"] + # A shipped venv's Windows redirectors read pyvenv.cfg `home`, which still names the + # build machine, so every executable in its Scripts fails on the user's machine. + if not (os.name == "nt" and environment == payload_venv(project_root)): + directories.append(venv_bin_dir(environment)) + prefix = [str(path) for path in directories if path.is_dir()] if prefix: os.environ["PATH"] = os.pathsep.join([*prefix, os.environ.get("PATH", "")]) diff --git a/tools/environments/local.py b/tools/environments/local.py index 792a8358b9..0b4399aa7c 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -601,11 +601,20 @@ _HERMES_BIN_DIR: "str | None | object" = _SENTINEL def _resolve_hermes_bin_dir() -> str | None: """Directory holding the ``hermes`` console-script, or None (cached). A gateway launched by systemd/cron/a desktop launcher lacks the install dir on PATH and bare - ``hermes`` exits 127. Order: ``which``; absolute ``sys.argv[0]`` naming a real - hermes executable; ``sys.executable``'s dir if it holds the shim.""" + ``hermes`` exits 127. Order: a sealed payload's own launcher dir; ``which``; absolute + ``sys.argv[0]`` naming a real hermes executable; ``sys.executable``'s dir if it holds + the shim.""" global _HERMES_BIN_DIR if _HERMES_BIN_DIR is not _SENTINEL: return _HERMES_BIN_DIR # type: ignore[return-value] + from pm.environments import payload_command_dir + + # A payload's venv also holds a `hermes`, but on Windows its redirector names the + # build machine's interpreter, so PATH order must not decide which copy children get. + payload_dir = payload_command_dir(Path(__file__).resolve().parents[2]) + if payload_dir is not None and payload_dir.is_dir(): + _HERMES_BIN_DIR = str(payload_dir) + return _HERMES_BIN_DIR which = shutil.which("hermes") argv0 = sys.argv[0] if sys.argv else "" base = os.path.basename(argv0).lower()