mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 10:25:42 +00:00
181 lines
5.6 KiB
TypeScript
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;
|
|
};
|