/** * path-shadow.ts, decide whether the copy of a command the updater maintains * is actually the copy the user's shell runs. * * The platform's update guarantee is "one verified swap mechanism, nothing * ever drifts". That guarantee holds only while there is exactly one copy of * each command reachable on PATH. When a second, older copy sits in a * directory that comes EARLIER on PATH than the install directory, the * updater keeps upgrading a file the user never reaches: `goodvibes-agent` * reports itself current, an older build answers the typing, and the product * looks like it is lying about its own capabilities. That is exactly what * happened with a leftover `~/.bun/bin/goodvibes-agent` link (1.18.1) sitting * at PATH position 2 while `~/.local/bin/goodvibes-agent` (1.21.0) sat at * position 21. * * This module is the one place that decides those facts. It is pure: every * filesystem and subprocess touch (does this file exist and is it runnable, * where does this symlink actually point, what does ` --version` say) * is injected, so the policy is provable under test with fake paths and no * real install. The surfaces wire the real I/O: * - a client's startup check reports a shadow before anything else renders; * - the installer's shell implementation (goodvibes-daemon scripts/install.sh) * mirrors these same rules in POSIX sh, because an installer cannot * import TypeScript. * * Removal is deliberately conservative. A copy is only ever offered for * removal when it is recognisably one of OUR programs, a link into an * installed `@pellux/goodvibes-*` package, or a file that answers * `--version` with ` `, and only when it lives inside the * user's own home directory. Everything else is reported and left alone. */ /** * How confident we are that a copy found on PATH is one of our own programs, * and therefore whether it is safe to offer to remove it. * * - `install-target`: the copy living in the directory the installer and * the auto-updater maintain. Never a removal candidate. * - `package-link`: an entry that resolves into an installed * `@pellux/goodvibes-*` package (a `bun add -g` / `npm i -g` link, or a * project-local dependency link). Removed by uninstalling that package. * - `our-binary`: a standalone file that answers `--version` with * ` `, i.e. a previous standalone install of the same * program. Removed by deleting the file. * - `unknown`: anything we cannot positively identify, including anything * outside the user's home directory. Reported, never removed. */ export type ShadowOwnership = 'install-target' | 'package-link' | 'our-binary' | 'unknown'; /** How a recognised copy is removed, and by what command. */ export interface ShadowRemoval { /** `package` = uninstall the owning package; `file` = delete the PATH entry itself. */ readonly kind: 'package' | 'file'; /** The `@pellux/...` package name, when `kind` is `package`. */ readonly packageName?: string | undefined; /** The exact command a user (or the installer) runs to remove this copy. */ readonly command: string; } /** One copy of a command found on PATH. */ export interface CommandCopy { /** The command name, e.g. `goodvibes-agent`. */ readonly command: string; /** The PATH directory the copy was found in. */ readonly directory: string; /** `/`, the path the shell would execute. */ readonly path: string; /** Zero-based position of `directory` in PATH. Lower wins. */ readonly pathIndex: number; /** The symlink-resolved path, or `path` when it is not a link. */ readonly resolvedPath: string; /** What ` --version` reported, when it could be probed. */ readonly version?: string | undefined; readonly ownership: ShadowOwnership; /** Present only when `ownership` is `package-link` or `our-binary`. */ readonly removal?: ShadowRemoval | undefined; } /** The reachability verdict for one command. */ export interface CommandShadowReport { readonly command: string; /** Every copy found, in PATH order. */ readonly copies: readonly CommandCopy[]; /** The copy the shell actually runs, the first on PATH. Absent when the command is not on PATH at all. */ readonly winner?: CommandCopy | undefined; /** The copy in the install directory. Absent when the install directory is not on PATH. */ readonly installed?: CommandCopy | undefined; /** * Copies that come earlier on PATH than the install directory's copy. A * non-empty list means the maintained install is unreachable by name. */ readonly shadowing: readonly CommandCopy[]; /** True when the install directory holds this command but is not on PATH at all. */ readonly installDirNotOnPath: boolean; } /** The whole-install verdict across every command the installer maintains. */ export interface ShadowScanResult { readonly reports: readonly CommandShadowReport[]; /** Commands whose maintained copy loses to an earlier PATH entry. */ readonly shadowed: readonly CommandShadowReport[]; /** True when any command is shadowed or the install directory is missing from PATH. */ readonly hasProblem: boolean; } export interface ShadowScanInput { /** Command names the installer/updater maintains, e.g. `['goodvibes', 'goodvibes-daemon', 'goodvibes-agent']`. */ readonly commands: readonly string[]; /** The directory the installer writes to and the auto-updater swaps in place. */ readonly installDir: string; /** PATH split into directories, in order, exactly as the shell would search them. */ readonly pathEntries: readonly string[]; /** The user's home directory. Nothing outside it is ever a removal candidate. */ readonly homeDir: string; /** True when `path` exists and is a runnable file. */ readonly isExecutableFile: (path: string) => boolean; /** Resolves a symlink chain; returns `path` unchanged when it is not a link or cannot be resolved. */ readonly realPath: (path: string) => string; /** * Runs ` --version` and returns its first line, or undefined when it * cannot be run. Optional: without it, `our-binary` can never be * established and such copies stay `unknown` (reported, never removed). */ readonly probeVersion?: ((path: string) => string | undefined) | undefined; } /** * Splits a raw PATH string into directories in search order. * * Empty entries (from a leading, trailing, or doubled separator) mean "the * current directory" to a POSIX shell. They are dropped rather than resolved: * a cwd-relative hit is not a stable install anyone can reason about, and * treating it as one would produce a different verdict per directory. * Duplicated directories collapse to their first occurrence, which is the * only position that can ever win. */ export declare function splitPathEntries(rawPath: string | undefined, separator?: string): string[]; /** True when `path` is `root` itself or lives underneath it. */ export declare function isWithinDirectory(path: string, root: string): boolean; /** * Extracts the owning `@pellux/goodvibes-*` package name from a resolved * path, by finding the LAST `node_modules` segment and reading the scoped * package directory that follows it. The last occurrence is the one that * owns the file: a nested `node_modules/a/node_modules/b/bin/x` belongs to * `b`, not `a`. */ export declare function owningPackageName(resolvedPath: string): string | undefined; /** * True when a `--version` line is one of ours: ` `, * which is the exact shape every goodvibes command prints. Anything else, * an unrelated program that happens to share the name, a wrapper script, a * `--version` that errors, fails, and the copy stays `unknown`. */ export declare function versionLineIdentifiesCommand(line: string | undefined, command: string): string | undefined; /** Scans PATH for every copy of every maintained command and decides which one wins. */ export declare function scanCommandShadows(input: ShadowScanInput): ShadowScanResult; /** * Renders one shadowed command as plain lines: which path wins, what version * each copy is, and the exact command that fixes it. No jargon, no severity * words, the user needs to know which file answers when they type the name. */ export declare function describeShadowReport(report: CommandShadowReport): string[]; /** Renders every problem in a scan, in command order. Empty when the scan is clean. */ export declare function describeShadowScan(result: ShadowScanResult): string[]; /** * The copies a caller may offer to remove: recognised copies of our own * program that are shadowing the maintained install. Deduplicated by path, * since one leftover package install typically provides several commands. */ export declare function removableShadows(result: ShadowScanResult): CommandCopy[]; //# sourceMappingURL=path-shadow.d.ts.map