Files
reverse-engineering-anything/src/application/HermesClientHome.ts
T

181 lines
5.6 KiB
TypeScript

import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { userInfo, type UserInfo } from "node:os";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from "node:path";
/** An unresolved Hermes profile must not be configured as the default home. */
export class HermesClientPathError extends Error {
constructor(
readonly path: string,
message: string,
) {
super(message);
this.name = "HermesClientPathError";
}
}
const defaultDirectory = (
home: string,
platform: NodeJS.Platform,
env: Readonly<NodeJS.ProcessEnv>,
): string => {
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? "";
return platform === "win32"
? join(
env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
`hermes${suffix}`,
)
: join(home, `.hermes${suffix}`);
};
const expandedDirectory = (
home: string,
platform: NodeJS.Platform,
env: Readonly<NodeJS.ProcessEnv>,
): string | undefined => {
const override = env.HERMES_HOME?.trim();
if (!override) return undefined;
const variables = override.replace(
/\$(\w+|\{[^}]*\})/gu,
(match, variable: string) => {
const key = variable.startsWith("{") ? variable.slice(1, -1) : variable;
return env[key] ?? match;
},
);
const expanded =
platform === "win32"
? variables.replace(
/%([^%]+)%/gu,
(match, variable: string) => env[variable] ?? match,
)
: variables;
if (expanded === "~") return home;
if (
expanded.startsWith("~/") ||
(platform === "win32" && expanded.startsWith("~\\"))
)
return join(home, expanded.slice(2));
if (expanded.startsWith("~")) {
// Named POSIX homes come from the account database, not HOME/USERPROFILE.
// Only the current account has a portable Node API; unresolved names must
// not become writable relative paths in the caller's working directory.
if (platform !== "win32") {
const separator = expanded.indexOf("/");
const username = expanded.slice(1, separator < 0 ? undefined : separator);
let account: UserInfo<string>;
try {
account = userInfo();
} catch {
throw new HermesClientPathError(
override,
"Cannot resolve the OS account for this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.",
);
}
if (account.username === username && isAbsolute(account.homedir))
return join(
account.homedir,
separator < 0 ? "" : expanded.slice(separator + 1),
);
}
throw new HermesClientPathError(
override,
"Cannot resolve this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.",
);
}
return resolve(expanded);
};
const physicalPath = (path: string): string => {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
};
const within = (root: string, path: string): boolean => {
const difference = relative(physicalPath(root), physicalPath(path));
return (
difference === "" ||
(!isAbsolute(difference) &&
difference !== ".." &&
!difference.startsWith(`..${sep}`))
);
};
const PROFILE_MARKERS = [
"config.yaml",
".env",
"SOUL.md",
"profile.yaml",
"auth.json",
"state.db",
];
const RESERVED_PROFILES = new Set(["hermes", "test", "tmp", "root", "sudo"]);
const liveProfile = (path: string): boolean => {
try {
if (!statSync(path).isDirectory()) return false;
try {
statSync(join(dirname(path), ".deleted", basename(path)));
return false;
} catch {
/* No tombstone. */
}
return PROFILE_MARKERS.some((marker) => {
try {
const status = lstatSync(join(path, marker));
return status.isFile() || status.isSymbolicLink();
} catch {
return false;
}
});
} catch {
return false;
}
};
/** Match Hermes CLI sticky-profile selection while retaining the configured path spelling. */
export const resolveHermesClientHome = (
home: string,
platform: NodeJS.Platform,
env: Readonly<NodeJS.ProcessEnv>,
): string => {
const native = defaultDirectory(home, platform, env);
const root = expandedDirectory(home, platform, env) ?? native;
// An explicit named profile bypasses the root's sticky selection in Hermes CLI.
if (basename(dirname(root)) === "profiles") return root;
const stickyRoot = within(native, root) ? native : root;
let name: string;
try {
// Python uses utf-8-sig and ignores unreadable / invalid-UTF8 active_profile files.
name = new TextDecoder("utf-8", { fatal: true })
.decode(readFileSync(join(stickyRoot, "active_profile")))
.trim();
} catch {
return root;
}
if (name === "" || name === "default") return root;
const canonical = name.toLowerCase();
if (canonical === "default") return root;
if (
!/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(canonical) ||
RESERVED_PROFILES.has(canonical)
)
throw new HermesClientPathError(
join(stickyRoot, "active_profile"),
`Invalid saved Hermes profile ${JSON.stringify(name)}. Run 'hermes profile use default' or repair active_profile.`,
);
// Hermes resolves both sticky selection and named profiles from its default
// root (including overrides beneath the native home), never mixed roots.
const selected = join(stickyRoot, "profiles", canonical);
if (!liveProfile(selected))
throw new HermesClientPathError(
selected,
`Saved Hermes profile ${JSON.stringify(name)} is missing, deleted, or has no profile identity. Run 'hermes profile use default' or restore that profile before configuring it.`,
);
return selected;
};