mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
feat(dsh): add Environment tab with local Python detection
Adds a read-only Environment tab to the ReMe Memory settings card so users can see why the ReMe service is unreachable before touching a terminal: - discover local Python interpreters via the PEP 514 registry, py launcher, PATH and conda (base + named envs), then verify every candidate by actually running it - classify origins (conda base/env, python.org, Microsoft Store, venv, system PATH), drop Store alias stubs, and suggest a preferred interpreter (Python >= 3.11, named conda envs first) - expose detection to the browser through a new remeEnv host gateway (TypertRemoteService) and render it with a connection health check - config gains optional authToken and pythonPath fields with unchanged defaults; existing memory settings keep their behavior Parsing is fixture-tested against real tool output on Windows (GBK codepages, localized reg labels, missing py launcher) and Linux.
This commit is contained in:
parent
c23cc64715
commit
6f1ecf06aa
14 changed files with 1820 additions and 73 deletions
405
integrations/dsh/src/client/env-tab.tsx
Normal file
405
integrations/dsh/src/client/env-tab.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
||||
|
||||
import { safeError } from "../endpoint.js";
|
||||
import type {
|
||||
PythonDetection,
|
||||
PythonEnv,
|
||||
PythonOrigin,
|
||||
} from "../pyenv/types.js";
|
||||
import { ReMeClient } from "../reme/client.js";
|
||||
import type { ReMeSettings } from "../types.js";
|
||||
import type { StatusRpc } from "./status-page.js";
|
||||
|
||||
const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
|
||||
|
||||
export const envEn = {
|
||||
connectionTitle: "Service connection",
|
||||
endpointLabel: "Service URL",
|
||||
checking: "Checking…",
|
||||
connected: "Connected",
|
||||
disconnected: "Not connected",
|
||||
retest: "Test connection",
|
||||
remoteNotice:
|
||||
"This service URL is not local, so local environment management is disabled.",
|
||||
pythonTitle: "Local Python",
|
||||
pythonHint:
|
||||
"ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
|
||||
detecting: "Detecting Python environments…",
|
||||
detectFailed: "Detection failed",
|
||||
refresh: "Refresh",
|
||||
emptyTitle: "No usable Python found",
|
||||
emptyHint:
|
||||
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
|
||||
miniconda: "Miniconda download",
|
||||
recommended: "recommended",
|
||||
okBadge: "OK",
|
||||
tooOld: "Needs 3.11+",
|
||||
originConda: "Conda ({name})",
|
||||
originPythonOrg: "python.org installer",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv environment",
|
||||
originSystem: "System PATH",
|
||||
originUnknown: "Unknown origin",
|
||||
errorsTitle: "Probe details",
|
||||
dependencyTitle: "ReMe dependency",
|
||||
configTitle: "ReMe parameters",
|
||||
controlTitle: "Service control",
|
||||
upcoming:
|
||||
"This section arrives with the next milestone of the environment feature.",
|
||||
};
|
||||
|
||||
export const envZh: typeof envEn = {
|
||||
connectionTitle: "服务连接",
|
||||
endpointLabel: "服务地址",
|
||||
checking: "正在检查…",
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
retest: "测试连接",
|
||||
remoteNotice: "当前服务地址不是本机地址,本地环境管理已停用。",
|
||||
pythonTitle: "本地 Python 环境",
|
||||
pythonHint:
|
||||
"ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
|
||||
detecting: "正在探测本地 Python 环境…",
|
||||
detectFailed: "探测失败",
|
||||
refresh: "重新探测",
|
||||
emptyTitle: "未检测到可用的 Python 环境",
|
||||
emptyHint:
|
||||
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
|
||||
miniconda: "下载 Miniconda",
|
||||
recommended: "推荐",
|
||||
okBadge: "可用",
|
||||
tooOld: "需要 3.11+",
|
||||
originConda: "Conda({name})",
|
||||
originPythonOrg: "python.org 安装器",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv 虚拟环境",
|
||||
originSystem: "系统 PATH",
|
||||
originUnknown: "来源未知",
|
||||
errorsTitle: "探测细节",
|
||||
dependencyTitle: "ReMe 依赖",
|
||||
configTitle: "ReMe 参数",
|
||||
controlTitle: "服务控制",
|
||||
upcoming: "该分区将随环境管理特性的后续里程碑提供。",
|
||||
};
|
||||
|
||||
export type EnvTranslator = (key: keyof typeof envEn) => string;
|
||||
|
||||
export interface ReMeEnvironmentTabProps {
|
||||
scope: ConfigForm<ReMeSettings>;
|
||||
rpc: StatusRpc;
|
||||
t: EnvTranslator;
|
||||
}
|
||||
|
||||
interface DetectState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: PythonDetection;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HealthState {
|
||||
phase: "loading" | "ok" | "fail";
|
||||
/** The endpoint this result was measured against. */
|
||||
endpoint: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isLocalEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDetection(value: unknown): PythonDetection {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!Array.isArray((value as { envs?: unknown }).envs)
|
||||
)
|
||||
throw new Error("Invalid environment detection response");
|
||||
return value as unknown as PythonDetection;
|
||||
}
|
||||
|
||||
function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
|
||||
switch (origin.kind) {
|
||||
case "conda":
|
||||
return t("originConda").replace("{name}", origin.envName);
|
||||
case "python.org":
|
||||
return t("originPythonOrg");
|
||||
case "windows-store":
|
||||
return t("originStore");
|
||||
case "venv":
|
||||
return t("originVenv");
|
||||
case "system":
|
||||
return t("originSystem");
|
||||
default:
|
||||
return t("originUnknown");
|
||||
}
|
||||
}
|
||||
|
||||
/** Environment management panel: local service connection, Python, service. */
|
||||
export function ReMeEnvironmentTab({
|
||||
scope,
|
||||
rpc,
|
||||
t,
|
||||
}: ReMeEnvironmentTabProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
(listener) => scope.subscribe(listener),
|
||||
() => scope.getSnapshot(),
|
||||
);
|
||||
const value = snapshot.value;
|
||||
const endpoint = value?.endpoint ?? "";
|
||||
const [detection, setDetection] = useState<DetectState>({
|
||||
phase: "loading",
|
||||
});
|
||||
const [health, setHealth] = useState<HealthState>({
|
||||
phase: "loading",
|
||||
endpoint: "",
|
||||
});
|
||||
const [detectTick, setDetectTick] = useState(0);
|
||||
const [healthTick, setHealthTick] = useState(0);
|
||||
const [savingExe, setSavingExe] = useState("");
|
||||
|
||||
const loadDetection = useCallback(
|
||||
async (signal: AbortSignal, refresh: boolean) => {
|
||||
try {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/python",
|
||||
{ args: { refresh } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
setDetection((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
data: parseDetection(result.value),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!signal.aborted)
|
||||
setDetection((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[rpc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadDetection(controller.signal, detectTick > 0);
|
||||
return () => controller.abort();
|
||||
}, [loadDetection, detectTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) return;
|
||||
const controller = new AbortController();
|
||||
const client = new ReMeClient({
|
||||
endpoint,
|
||||
requestTimeoutMs: value?.requestTimeoutMs ?? 10000,
|
||||
backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000,
|
||||
});
|
||||
void client
|
||||
.healthCheck({ signal: controller.signal })
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (result.ok)
|
||||
setHealth({
|
||||
phase: "ok",
|
||||
endpoint,
|
||||
version: result.health?.version ?? "",
|
||||
});
|
||||
else setHealth({ phase: "fail", endpoint, error: result.error });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setHealth({ phase: "fail", endpoint, error: safeError(error) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// Reload when connection settings or the manual retest tick change.
|
||||
}, [
|
||||
endpoint,
|
||||
value?.requestTimeoutMs,
|
||||
value?.backgroundTimeoutMs,
|
||||
healthTick,
|
||||
]);
|
||||
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
const select = (env: PythonEnv): void => {
|
||||
if (!value || !snapshot.writable || savingExe) return;
|
||||
setSavingExe(env.exe);
|
||||
void scope
|
||||
.mutate(
|
||||
[{ op: "set", path: ["pythonPath"], value: env.exe }],
|
||||
snapshot.revision,
|
||||
)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSavingExe(""));
|
||||
};
|
||||
|
||||
const local = isLocalEndpoint(endpoint);
|
||||
const selectedExe = value?.pythonPath ?? "";
|
||||
const healthCurrent =
|
||||
health.phase !== "loading" && health.endpoint === endpoint;
|
||||
|
||||
return (
|
||||
<div className="reme-env">
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("connectionTitle")}</h4>
|
||||
<div className="reme-env-row">
|
||||
<span className="reme-env-label">{t("endpointLabel")}</span>
|
||||
<code className="reme-env-endpoint">{endpoint}</code>
|
||||
{!healthCurrent ? (
|
||||
<span className="reme-status-badge">
|
||||
<i />
|
||||
{t("checking")}
|
||||
</span>
|
||||
) : health.phase === "ok" ? (
|
||||
<span className="reme-status-badge ok">
|
||||
<i />
|
||||
{health.version ? `ReMe ${health.version}` : t("connected")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="reme-status-badge bad">
|
||||
<i />
|
||||
{t("disconnected")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setHealth({ phase: "loading", endpoint });
|
||||
setHealthTick((tick) => tick + 1);
|
||||
}}
|
||||
>
|
||||
{t("retest")}
|
||||
</button>
|
||||
</div>
|
||||
{healthCurrent && health.phase === "fail" && health.error ? (
|
||||
<p className="reme-settings-error">{health.error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
{!local ? (
|
||||
<p className="reme-settings-muted reme-env-notice">
|
||||
{t("remoteNotice")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("pythonTitle")}</h4>
|
||||
<p className="reme-settings-muted">{t("pythonHint")}</p>
|
||||
<div className="reme-env-row">
|
||||
{detection.phase === "loading" ? (
|
||||
<span className="reme-settings-muted">{t("detecting")}</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setDetection({ phase: "loading" });
|
||||
setDetectTick((tick) => tick + 1);
|
||||
}}
|
||||
>
|
||||
{t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
{detection.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("detectFailed")}
|
||||
{detection.error ? `: ${detection.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{detected && detected.envs.length === 0 ? (
|
||||
<div className="reme-env-hint">
|
||||
<strong>{t("emptyTitle")}</strong>
|
||||
<span>{t("emptyHint")}</span>
|
||||
<a
|
||||
className="reme-settings-button"
|
||||
href={MINICONDA_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("miniconda")}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{detected && detected.envs.length > 0 ? (
|
||||
<div className="reme-env-list">
|
||||
{detected.envs.map((env) => (
|
||||
<label
|
||||
key={env.exe}
|
||||
className={`reme-env-python${
|
||||
selectedExe === env.exe ? " active" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="reme-python-interpreter"
|
||||
checked={selectedExe === env.exe}
|
||||
disabled={
|
||||
!env.ok || savingExe !== "" || !snapshot.writable
|
||||
}
|
||||
onChange={() => select(env)}
|
||||
/>
|
||||
<span className="reme-env-python-main">
|
||||
<strong>
|
||||
{`Python ${env.version || "?"}`}
|
||||
{selectedExe !== env.exe &&
|
||||
env.exe === detected.suggested
|
||||
? ` · ${t("recommended")}`
|
||||
: ""}
|
||||
</strong>
|
||||
<small>{`${originLabel(t, env.origin)} · ${
|
||||
env.exe
|
||||
}`}</small>
|
||||
</span>
|
||||
<span
|
||||
className={`reme-status-badge ${env.ok ? "ok" : "bad"}`}
|
||||
>
|
||||
<i />
|
||||
{env.ok ? t("okBadge") : t("tooOld")}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{detected && detected.errors.length > 0 ? (
|
||||
<details className="reme-settings-details">
|
||||
<summary>{t("errorsTitle")}</summary>
|
||||
<pre>{detected.errors.join("\n")}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("dependencyTitle")}</h4>
|
||||
<p className="reme-settings-muted reme-env-upcoming">
|
||||
{t("upcoming")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("configTitle")}</h4>
|
||||
<p className="reme-settings-muted reme-env-upcoming">
|
||||
{t("upcoming")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("controlTitle")}</h4>
|
||||
<p className="reme-settings-muted reme-env-upcoming">
|
||||
{t("upcoming")}
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,12 @@ import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
|
|||
|
||||
import type { ReMeSettings } from "../types.js";
|
||||
import { validEndpoint } from "../endpoint.js";
|
||||
import {
|
||||
envEn,
|
||||
envZh,
|
||||
ReMeEnvironmentTab,
|
||||
type EnvTranslator,
|
||||
} from "./env-tab.js";
|
||||
import {
|
||||
ReMeStatusPage,
|
||||
statusEn,
|
||||
|
|
@ -14,6 +20,7 @@ import { styles } from "./styles.js";
|
|||
|
||||
const NS = "reme.settings";
|
||||
const STATUS_NS = "reme.status";
|
||||
const ENV_NS = "reme.environment";
|
||||
const SETTINGS_NS = "reme-memory";
|
||||
const FIELDS = [
|
||||
"endpoint",
|
||||
|
|
@ -42,6 +49,7 @@ interface ClientContext {
|
|||
locale: {
|
||||
bind(namespace: typeof NS): Translator;
|
||||
bind(namespace: typeof STATUS_NS): StatusTranslator;
|
||||
bind(namespace: typeof ENV_NS): EnvTranslator;
|
||||
register(
|
||||
namespace: string,
|
||||
dictionaries: {
|
||||
|
|
@ -84,6 +92,8 @@ interface Draft {
|
|||
interface ReMeCardProps {
|
||||
scope: ConfigForm<ReMeSettings>;
|
||||
t: Translator;
|
||||
rpc?: StatusRpc;
|
||||
envT?: EnvTranslator;
|
||||
view?: "summary" | "page";
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +131,8 @@ const en = {
|
|||
retryDelay: "Retry delay (ms)",
|
||||
retryMaxDelay: "Maximum retry delay (ms)",
|
||||
diagnostics: "Service diagnostics",
|
||||
tabMemory: "Memory settings",
|
||||
tabEnvironment: "Environment",
|
||||
processMemory: "Process memory",
|
||||
componentMemory: "Component memory",
|
||||
components: "Health components",
|
||||
|
|
@ -196,6 +208,8 @@ const zh: typeof en = {
|
|||
retryDelay: "失败重试间隔(毫秒)",
|
||||
retryMaxDelay: "最大重试间隔(毫秒)",
|
||||
diagnostics: "服务诊断",
|
||||
tabMemory: "记忆设置",
|
||||
tabEnvironment: "环境管理",
|
||||
processMemory: "进程内存",
|
||||
componentMemory: "组件内存",
|
||||
components: "健康组件",
|
||||
|
|
@ -250,7 +264,12 @@ export function apply(ctx: ClientContext): void {
|
|||
() => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }),
|
||||
"remeMemory.statusLocale()",
|
||||
);
|
||||
ctx.effect(
|
||||
() => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }),
|
||||
"remeMemory.envLocale()",
|
||||
);
|
||||
ctx.effect(() => installStyles(), "remeMemory.settingsStyles()");
|
||||
const envT = ctx.locale.bind(ENV_NS);
|
||||
const scope = ctx.configForms.get<ReMeSettings>(SETTINGS_NS);
|
||||
ctx.effect(
|
||||
() =>
|
||||
|
|
@ -262,7 +281,12 @@ export function apply(ctx: ClientContext): void {
|
|||
key: "@agentscope-ai/reme-dsh-plugin#reme-memory",
|
||||
label: () => t("title"),
|
||||
locale: NS,
|
||||
inject: () => ({ scope, t }),
|
||||
inject: () => ({
|
||||
scope,
|
||||
t,
|
||||
rpc: ctx.get("connection").rpc,
|
||||
envT,
|
||||
}),
|
||||
},
|
||||
ReMeSettingsCard,
|
||||
),
|
||||
|
|
@ -291,6 +315,8 @@ export function apply(ctx: ClientContext): void {
|
|||
function ReMeSettingsCard({
|
||||
scope,
|
||||
t,
|
||||
rpc,
|
||||
envT,
|
||||
view,
|
||||
}: ReMeCardProps): JSX.Element | null {
|
||||
const snapshot = useSyncExternalStore(
|
||||
|
|
@ -304,6 +330,7 @@ function ReMeSettingsCard({
|
|||
const [resetAll, setResetAll] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
|
||||
const [tab, setTab] = useState<"memory" | "environment">("memory");
|
||||
const value = snapshot.value;
|
||||
const draft =
|
||||
draftOverride?.value ??
|
||||
|
|
@ -316,7 +343,20 @@ function ReMeSettingsCard({
|
|||
(value !== undefined &&
|
||||
draft !== undefined &&
|
||||
!sameDraft(draft, draftFrom(value)));
|
||||
const validation = draft === undefined ? undefined : parseDraft(draft);
|
||||
const validation =
|
||||
draft === undefined
|
||||
? undefined
|
||||
: parseDraft(
|
||||
draft,
|
||||
value && isRecord(value)
|
||||
? {
|
||||
authToken:
|
||||
typeof value.authToken === "string" ? value.authToken : "",
|
||||
pythonPath:
|
||||
typeof value.pythonPath === "string" ? value.pythonPath : "",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
|
||||
if (value === undefined) return;
|
||||
|
|
@ -373,79 +413,115 @@ function ReMeSettingsCard({
|
|||
return (
|
||||
<div className="reme-settings-card">
|
||||
<div className="reme-settings-body">
|
||||
{dirty ? <p className="reme-settings-pending">{t("unsaved")}</p> : null}
|
||||
{snapshot.status === "loading" ||
|
||||
draft === undefined ||
|
||||
value === undefined ? (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
<div className="reme-settings-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "memory"}
|
||||
className={tab === "memory" ? "active" : ""}
|
||||
onClick={() => setTab("memory")}
|
||||
>
|
||||
{t("tabMemory")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "environment"}
|
||||
className={tab === "environment" ? "active" : ""}
|
||||
onClick={() => setTab("environment")}
|
||||
>
|
||||
{t("tabEnvironment")}
|
||||
</button>
|
||||
</div>
|
||||
{tab === "environment" ? (
|
||||
rpc && envT ? (
|
||||
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
|
||||
) : (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<fieldset
|
||||
className="reme-settings-fields"
|
||||
disabled={saving || !snapshot.writable}
|
||||
>
|
||||
<SettingsForm draft={draft} update={update} t={t} />
|
||||
</fieldset>
|
||||
{!snapshot.writable ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("readOnly")}
|
||||
</p>
|
||||
{dirty ? (
|
||||
<p className="reme-settings-pending">{t("unsaved")}</p>
|
||||
) : null}
|
||||
{validation === undefined ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("invalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reme-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset"
|
||||
disabled={!snapshot.writable || saving}
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
value: draftFrom(baseSettings(snapshot.base, value)),
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
setResetAll(true);
|
||||
setSaveState("");
|
||||
}}
|
||||
>
|
||||
{t("reset")}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{saveState === "failed" ? (
|
||||
<span className="reme-settings-error">{t("saveFailed")}</span>
|
||||
{snapshot.status === "loading" ||
|
||||
draft === undefined ||
|
||||
value === undefined ? (
|
||||
<p className="reme-settings-muted">{t("loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<fieldset
|
||||
className="reme-settings-fields"
|
||||
disabled={saving || !snapshot.writable}
|
||||
>
|
||||
<SettingsForm draft={draft} update={update} t={t} />
|
||||
</fieldset>
|
||||
{!snapshot.writable ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("readOnly")}
|
||||
</p>
|
||||
) : null}
|
||||
{saveState === "saved" ? (
|
||||
<span className="reme-settings-success">{t("saved")}</span>
|
||||
{validation === undefined ? (
|
||||
<p className="reme-settings-error" role="status">
|
||||
{t("invalid")}
|
||||
</p>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => {
|
||||
setDraft(undefined);
|
||||
setResetAll(false);
|
||||
setSaveState("");
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
!snapshot.writable ||
|
||||
!dirty ||
|
||||
validation === undefined ||
|
||||
saving
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t(saving ? "saving" : "save")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="reme-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset"
|
||||
disabled={!snapshot.writable || saving}
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
value: draftFrom(baseSettings(snapshot.base, value)),
|
||||
revision: snapshot.revision,
|
||||
});
|
||||
setResetAll(true);
|
||||
setSaveState("");
|
||||
}}
|
||||
>
|
||||
{t("reset")}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{saveState === "failed" ? (
|
||||
<span className="reme-settings-error">
|
||||
{t("saveFailed")}
|
||||
</span>
|
||||
) : null}
|
||||
{saveState === "saved" ? (
|
||||
<span className="reme-settings-success">
|
||||
{t("saved")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => {
|
||||
setDraft(undefined);
|
||||
setResetAll(false);
|
||||
setSaveState("");
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
!snapshot.writable ||
|
||||
!dirty ||
|
||||
validation === undefined ||
|
||||
saving
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t(saving ? "saving" : "save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -677,7 +753,10 @@ function draftFrom(value: ReMeSettings): Draft {
|
|||
};
|
||||
}
|
||||
|
||||
function parseDraft(draft: Draft): ReMeSettings | undefined {
|
||||
function parseDraft(
|
||||
draft: Draft,
|
||||
passthrough?: Pick<ReMeSettings, "authToken" | "pythonPath">,
|
||||
): ReMeSettings | undefined {
|
||||
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
|
||||
const backgroundTimeoutMs = boundedInteger(
|
||||
draft.backgroundTimeoutMs,
|
||||
|
|
@ -718,6 +797,8 @@ function parseDraft(draft: Draft): ReMeSettings | undefined {
|
|||
const [hour, minute] = draft.dreamTime.split(":");
|
||||
return {
|
||||
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
|
||||
authToken: passthrough?.authToken ?? "",
|
||||
pythonPath: passthrough?.pythonPath ?? "",
|
||||
requestTimeoutMs: requestTimeoutMs!,
|
||||
backgroundTimeoutMs: backgroundTimeoutMs!,
|
||||
shutdownTimeoutMs: shutdownTimeoutMs!,
|
||||
|
|
|
|||
|
|
@ -31,5 +31,27 @@ export const styles = `
|
|||
.reme-components-hero{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:linear-gradient(135deg,color-mix(in srgb,#21a366 6%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:18px 20px}.reme-components-hero h3{margin:3px 0 0;font-size:20px}.reme-components-hero p{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px}.reme-components-score{display:grid;grid-template-columns:auto auto;align-items:baseline}.reme-components-score strong{font-size:25px}.reme-components-score>span{color:var(--dsw-alias-label-tertiary);font-size:13px}.reme-components-score small{grid-column:1 / -1;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-status-components .reme-status-component-grid{margin-top:12px}.reme-status-component{position:relative;overflow:hidden;padding:15px}.reme-status-component:before{position:absolute;top:0;right:0;left:0;height:2px;background:var(--dsw-alias-label-dimmed);content:""}.reme-status-component.healthy:before{background:#21a366}.reme-component-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px!important}.reme-component-identity>span{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:10px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:700}.reme-component-identity>div{display:flex;min-width:0;flex-direction:column;gap:2px}
|
||||
.reme-library-hero{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:13px;border:1px solid var(--dsw-alias-border-l2);border-bottom:0;border-radius:14px 14px 0 0;background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:15px 17px}.reme-library-hero.knowledge{background:linear-gradient(135deg,color-mix(in srgb,#7656c9 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3))}.reme-library-mark{display:grid;width:38px;height:38px;place-items:center;border-radius:11px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:700}.reme-library-hero h3{margin:0;font-size:15px}.reme-library-hero p{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px}.reme-library-count{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:0 4px;text-align:right}.reme-library-count strong{font-size:18px}.reme-library-count span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-library-count code{grid-column:1 / -1;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-file-search{position:sticky;z-index:1;top:-8px;display:flex;align-items:center;gap:6px;background:inherit;padding:0 0 8px}.reme-file-search input{width:100%;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:7px 9px;color:var(--dsw-alias-label-primary);font:inherit;font-size:10px}.reme-file-search input:focus{border-color:var(--dsw-alias-brand-primary);outline:0}.reme-file-search>span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header{position:sticky;z-index:1;top:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 92%,transparent);padding:14px 18px;backdrop-filter:blur(10px)}.reme-document-preview>header span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header h3{margin:2px 0;font-size:15px}.reme-document-preview>header code{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-frontmatter,.reme-document-content{padding:16px 18px}.reme-frontmatter{border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,transparent)}.reme-frontmatter h4,.reme-document-content>h4{margin:0 0 11px;color:var(--dsw-alias-label-tertiary);font-size:9px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.reme-frontmatter dl{display:grid;gap:8px;margin:0}.reme-frontmatter dl>div{display:grid;grid-template-columns:minmax(76px,max-content) minmax(0,1fr);gap:14px}.reme-frontmatter dt{color:var(--dsw-alias-label-tertiary);font:600 10px ui-monospace,SFMono-Regular,monospace}.reme-frontmatter dd{margin:0;overflow-wrap:anywhere;font-size:11px;line-height:1.5;white-space:pre-wrap}.reme-document-content{padding-bottom:38px}
|
||||
@media(max-width:900px){.reme-status-metric-grid,.reme-status-dream-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.reme-settings-tabs{display:flex;gap:2px;margin-top:10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-settings-tabs button{position:relative;appearance:none;border:0;background:none;padding:9px 12px;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;cursor:pointer}
|
||||
.reme-settings-tabs button:hover{color:var(--dsw-alias-label-primary)}
|
||||
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
|
||||
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
|
||||
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
.reme-env{display:flex;flex-direction:column}
|
||||
.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0}
|
||||
.reme-env-row+.reme-settings-error{margin:0}
|
||||
.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}
|
||||
.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
|
||||
.reme-env-notice{margin:12px 0 0}
|
||||
.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer}
|
||||
.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
|
||||
.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere}
|
||||
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
||||
.reme-env-hint strong{color:var(--dsw-alias-label-primary)}
|
||||
.reme-env-upcoming{margin:0;padding:4px 0 12px}
|
||||
.reme-env .reme-settings-details{margin-top:8px}
|
||||
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ export const Config = z.object({
|
|||
"ReMe HTTP service URL",
|
||||
assertEndpoint,
|
||||
),
|
||||
authToken: z
|
||||
.string()
|
||||
.default("")
|
||||
.description(
|
||||
"Optional bearer token for a self-hosted gateway in front of ReMe",
|
||||
)
|
||||
.volatile(),
|
||||
pythonPath: z
|
||||
.string()
|
||||
.default("")
|
||||
.description("Local Python interpreter selected in environment management")
|
||||
.volatile(),
|
||||
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
|
||||
backgroundTimeoutMs: z
|
||||
.natural()
|
||||
|
|
@ -86,6 +98,8 @@ function checkedString(
|
|||
|
||||
const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
|
||||
endpoint: "http://127.0.0.1:2333",
|
||||
authToken: "",
|
||||
pythonPath: "",
|
||||
requestTimeoutMs: 10000,
|
||||
backgroundTimeoutMs: 3600000,
|
||||
shutdownTimeoutMs: 5000,
|
||||
|
|
@ -138,6 +152,14 @@ export function resolveConfig(
|
|||
|
||||
config.endpoint = stripTrailingSlashes(String(config.endpoint));
|
||||
assertEndpoint(config.endpoint);
|
||||
config.authToken =
|
||||
typeof config.authToken === "string" ? config.authToken.trim() : "";
|
||||
if (config.authToken && !/^[\x21-\x7e]{1,512}$/.test(config.authToken))
|
||||
throw new TypeError(
|
||||
"ReMe auth token must be 1-512 visible ASCII characters without newlines",
|
||||
);
|
||||
config.pythonPath =
|
||||
typeof config.pythonPath === "string" ? config.pythonPath.trim() : "";
|
||||
const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`;
|
||||
const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`;
|
||||
config.autoMemoryQueueDir = resolve(
|
||||
|
|
|
|||
29
integrations/dsh/src/env-gateway.ts
Normal file
29
integrations/dsh/src/env-gateway.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
||||
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
import type { ReMeEnvManager } from "./env-manager.js";
|
||||
|
||||
declare module "@deepseek-ai/cordis" {
|
||||
interface Context {
|
||||
/** Local environment manager mounted by the DSH adapter. */
|
||||
remeEnv: ReMeEnvManager;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only Host projection consumed by the environment management tab. */
|
||||
export class ReMeEnvGateway extends TypertRemoteService {
|
||||
static inject = ["remeEnv"];
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, "remeEnv");
|
||||
}
|
||||
|
||||
/** Detect local Python interpreters; cached briefly unless refreshed. */
|
||||
@Remote("python")
|
||||
async python(input: { refresh?: boolean } = {}): Promise<PythonDetection> {
|
||||
return this.ctx.remeEnv.detectPython(input);
|
||||
}
|
||||
}
|
||||
|
||||
export default ReMeEnvGateway;
|
||||
33
integrations/dsh/src/env-manager.ts
Normal file
33
integrations/dsh/src/env-manager.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import { detectPythonEnvs } from "./pyenv/detect.js";
|
||||
import { createNodeRunner } from "./pyenv/runner.js";
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Host-side local environment manager. Read-only in this milestone: it
|
||||
* discovers Python interpreters and caches the answer briefly so opening
|
||||
* the environment tab does not re-run probes on every render.
|
||||
*/
|
||||
export class ReMeEnvManager {
|
||||
private cache?: { at: number; detection: PythonDetection };
|
||||
|
||||
async detectPython(
|
||||
options: { refresh?: boolean } = {},
|
||||
): Promise<PythonDetection> {
|
||||
if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS)
|
||||
return this.cache.detection;
|
||||
const detection = await detectPythonEnvs({
|
||||
run: createNodeRunner(process.env),
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homedir: homedir(),
|
||||
exists: existsSync,
|
||||
});
|
||||
this.cache = { at: Date.now(), detection };
|
||||
return detection;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import type {} from "@deepseek-ai/dsh-session";
|
|||
import { ReMeClient } from "./reme/client.js";
|
||||
|
||||
import { resolveConfig } from "./config.js";
|
||||
import { ReMeEnvGateway } from "./env-gateway.js";
|
||||
import { ReMeEnvManager } from "./env-manager.js";
|
||||
import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js";
|
||||
import { ReMeRuntime } from "./runtime.js";
|
||||
import { ReMeStatusGateway } from "./status-gateway.js";
|
||||
|
|
@ -25,7 +27,9 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void {
|
|||
return () => runtime.disposeAll();
|
||||
}, "remeMemory.lifecycle()");
|
||||
ctx.provide("remeMemory", runtime);
|
||||
ctx.provide("remeEnv", new ReMeEnvManager());
|
||||
void ctx.plugin(ReMeStatusGateway);
|
||||
void ctx.plugin(ReMeEnvGateway);
|
||||
ctx.effect(
|
||||
() => registerReMeTools(ctx, client, current),
|
||||
"remeMemory.tools()",
|
||||
|
|
|
|||
524
integrations/dsh/src/pyenv/detect.ts
Normal file
524
integrations/dsh/src/pyenv/detect.ts
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { join as posixJoin } from "node:path/posix";
|
||||
|
||||
import { safeError } from "../endpoint.js";
|
||||
import type { RunCommand, RunResult } from "./runner.js";
|
||||
import type { PythonDetection, PythonEnv, PythonOrigin } from "./types.js";
|
||||
|
||||
/** ReMe requires Python 3.11 or newer. */
|
||||
export const MINIMUM_PYTHON = "3.11";
|
||||
|
||||
/**
|
||||
* Ask one interpreter for its real executable, version and base prefix.
|
||||
* Kept as a single self-contained -c snippet so no helper files are needed.
|
||||
*/
|
||||
const VERIFY_SNIPPET =
|
||||
'import sys,json;print(json.dumps({"exe":sys.executable,"ver":"%d.%d.%d"%sys.version_info[:3],"base":sys.base_prefix}))';
|
||||
|
||||
export interface DetectIO {
|
||||
run: RunCommand;
|
||||
platform: NodeJS.Platform;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homedir: string;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface VerifyInfo {
|
||||
exe: string;
|
||||
version: string;
|
||||
basePrefix: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pure parsers (unit-tested against real tool output fixtures). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Compare "major.minor[.patch]" strings numerically. */
|
||||
export function versionAtLeast(
|
||||
version: string,
|
||||
minimum: string = MINIMUM_PYTHON,
|
||||
): boolean {
|
||||
const actual = version.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (actual.length < 2 || actual.some((part) => !Number.isFinite(part)))
|
||||
return false;
|
||||
const wanted = minimum.split(".").map((part) => Number.parseInt(part, 10));
|
||||
for (let index = 0; index < wanted.length; index += 1) {
|
||||
const left = actual[index] ?? 0;
|
||||
const right = wanted[index] ?? 0;
|
||||
if (left !== right) return left > right;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Parse the VERIFY_SNIPPET line out of mixed stdout (banners, warnings). */
|
||||
export function parseVerifyOutput(text: string): VerifyInfo | undefined {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const exe = typeof value.exe === "string" ? value.exe : "";
|
||||
const version = typeof value.ver === "string" ? value.ver : "";
|
||||
const base = typeof value.base === "string" ? value.base : "";
|
||||
if (exe && version) return { exe, version, basePrefix: base };
|
||||
} catch {
|
||||
// Not JSON; try the next line.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface RegPython {
|
||||
exe: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `reg query <hive> /s /ve` output for PEP 514 InstallPath defaults.
|
||||
* The default-value label is localized and arrives mojibaked when reg.exe
|
||||
* writes GBK into a UTF-8 pipe, so match the ASCII "REG_SZ" marker instead.
|
||||
*/
|
||||
export function parseRegQuery(text: string): RegPython[] {
|
||||
const found: RegPython[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const key = (lines[index] ?? "").trim();
|
||||
if (!key.startsWith("HKEY_") || !key.endsWith("\\InstallPath")) continue;
|
||||
for (
|
||||
let offset = 1;
|
||||
offset <= 3 && index + offset < lines.length;
|
||||
offset += 1
|
||||
) {
|
||||
const line = (lines[index + offset] ?? "").trim();
|
||||
if (line.startsWith("HKEY_")) break;
|
||||
const match = /REG_SZ\s+(.+)$/i.exec(line);
|
||||
if (!match) continue;
|
||||
const dir = (match[1] ?? "").trim();
|
||||
if (!dir || /^\(.*\)$/.test(dir)) break;
|
||||
const exe = /python[^\\/]*\.exe$/i.test(dir)
|
||||
? dir
|
||||
: `${dir.replace(/[\\/]+$/, "")}\\python.exe`;
|
||||
found.push({ exe, source: `registry ${key}` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Parse `py -0p` lines: "-V:3.12 * D:\...\python.exe". */
|
||||
export function parsePyListPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = /-V:\d+(?:\.\d+)?\s+\*?\s*(\S.*)$/.exec(line);
|
||||
const exe = (match?.[1] ?? "").trim();
|
||||
if (exe && /python[^\\/]*\.exe$/i.test(exe)) paths.push(exe);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Parse `where`/`which -a` output into trimmed, non-empty lines. */
|
||||
export function parsePathList(text: string): string[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Store installs real interpreters under
|
||||
* WindowsApps\PythonSoftwareFoundation.*, while the alias stubs sit directly
|
||||
* in WindowsApps and only open the Store. Only the stubs must be dropped.
|
||||
*/
|
||||
export function isWindowsAppsAlias(exe: string): boolean {
|
||||
const normalized = exe.replaceAll("/", "\\").toLowerCase();
|
||||
const cut = normalized.lastIndexOf("\\");
|
||||
if (cut < 0) return false;
|
||||
const parent = normalized.slice(0, cut);
|
||||
const parentName = parent.slice(parent.lastIndexOf("\\") + 1);
|
||||
return parentName === "windowsapps";
|
||||
}
|
||||
|
||||
function isWindowsStorePackage(exe: string): boolean {
|
||||
return /\\windowsapps\\pythonsoftwarefoundation\./i.test(
|
||||
exe.replaceAll("/", "\\"),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCondaEnvs(text: string): string[] {
|
||||
try {
|
||||
const value = JSON.parse(text) as { envs?: unknown };
|
||||
if (!Array.isArray(value.envs)) return [];
|
||||
return value.envs.filter(
|
||||
(item): item is string => typeof item === "string" && item.length > 0,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCondaRoot(text: string): string {
|
||||
try {
|
||||
const value = JSON.parse(text) as { root_prefix?: unknown };
|
||||
return typeof value.root_prefix === "string" ? value.root_prefix : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Conda command runner: prefer the real conda.exe, fall back to .bat. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type CondaRunner = (
|
||||
args: string[],
|
||||
timeoutMs?: number,
|
||||
) => Promise<RunResult>;
|
||||
|
||||
/** Derive the conda installation root from any conda executable path. */
|
||||
export function condaRootFromPath(condaPath: string): string {
|
||||
const dir = dirname(condaPath);
|
||||
const lower = dir.toLowerCase();
|
||||
if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin"))
|
||||
return dirname(dirname(dir));
|
||||
if (lower.endsWith("\\scripts") || lower.endsWith("/scripts"))
|
||||
return dirname(dir);
|
||||
if (lower.endsWith("\\condabin") || lower.endsWith("/condabin"))
|
||||
return dirname(dir);
|
||||
if (lower.endsWith("\\bin") || lower.endsWith("/bin")) return dirname(dir);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows ships conda as .bat files that Node cannot spawn without a shell;
|
||||
* run the real Scripts\conda.exe when present, otherwise wrap the .bat in
|
||||
* cmd.exe with verbatim argv. The path comes from discovery, never user input.
|
||||
*/
|
||||
export function makeCondaRunner(
|
||||
run: RunCommand,
|
||||
condaPath: string,
|
||||
windows: boolean,
|
||||
exists: (path: string) => boolean,
|
||||
): CondaRunner {
|
||||
const root = condaRootFromPath(condaPath);
|
||||
const exeCandidates: string[] = [];
|
||||
if (root && windows) exeCandidates.push(join(root, "Scripts", "conda.exe"));
|
||||
if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath);
|
||||
return async (args, timeoutMs = 15_000) => {
|
||||
for (const candidate of exeCandidates) {
|
||||
if (!exists(candidate)) continue;
|
||||
const result = await run(candidate, args, { timeoutMs });
|
||||
if (!result.error) return result;
|
||||
}
|
||||
if (windows)
|
||||
return run(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", `"${condaPath}" ${args.join(" ")}`],
|
||||
{ timeoutMs, windowsVerbatimArguments: true },
|
||||
);
|
||||
return run(condaPath, args, { timeoutMs });
|
||||
};
|
||||
}
|
||||
|
||||
function commonCondaPaths(io: DetectIO, windows: boolean): string[] {
|
||||
const programData = io.env["ProgramData"] ?? "";
|
||||
const candidates = windows
|
||||
? [
|
||||
join(io.homedir, "miniconda3", "Scripts", "conda.exe"),
|
||||
join(io.homedir, "anaconda3", "Scripts", "conda.exe"),
|
||||
join(programData, "miniconda3", "Scripts", "conda.exe"),
|
||||
join(programData, "anaconda3", "Scripts", "conda.exe"),
|
||||
]
|
||||
: [
|
||||
join(io.homedir, "miniconda3", "bin", "conda"),
|
||||
join(io.homedir, "anaconda3", "bin", "conda"),
|
||||
join(io.homedir, "miniforge3", "bin", "conda"),
|
||||
"/opt/miniconda3/bin/conda",
|
||||
"/opt/anaconda3/bin/conda",
|
||||
"/opt/homebrew/Caskroom/miniconda/base/bin/conda",
|
||||
];
|
||||
return candidates.filter((path) => isAbsolute(path));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Orchestration. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface Candidate {
|
||||
exe: string;
|
||||
sources: Set<string>;
|
||||
}
|
||||
|
||||
function normalizeExe(exe: string, windows: boolean): string {
|
||||
const trimmed = exe.trim();
|
||||
if (!trimmed) return "";
|
||||
// Unify separators for the simulated platform: resolve() follows the host,
|
||||
// but detection must behave identically on every host.
|
||||
const unified = windows
|
||||
? trimmed.replaceAll("/", "\\")
|
||||
: trimmed.replaceAll("\\", "/");
|
||||
return windows ? unified.toLowerCase() : unified;
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
candidates: Map<string, Candidate>,
|
||||
exe: string,
|
||||
source: string,
|
||||
windows: boolean,
|
||||
): void {
|
||||
const key = normalizeExe(exe, windows);
|
||||
if (!key) return;
|
||||
const existing = candidates.get(key);
|
||||
if (existing) existing.sources.add(source);
|
||||
else candidates.set(key, { exe: exe.trim(), sources: new Set([source]) });
|
||||
}
|
||||
|
||||
function pathContains(dir: string, exe: string, windows: boolean): boolean {
|
||||
const base = normalizeExe(resolve(dir), windows);
|
||||
const full = normalizeExe(resolve(exe), windows);
|
||||
return full.startsWith(`${base}${windows ? "\\" : "/"}`);
|
||||
}
|
||||
|
||||
/** prefix of an interpreter install from its executable location. */
|
||||
function pythonPrefix(exe: string, windows: boolean): string {
|
||||
const parent = dirname(exe);
|
||||
return windows || basename(parent) !== "bin" ? parent : dirname(parent);
|
||||
}
|
||||
|
||||
function isVenv(info: VerifyInfo, windows: boolean): boolean {
|
||||
if (!info.basePrefix) return false;
|
||||
return (
|
||||
normalizeExe(pythonPrefix(info.exe, windows), windows) !==
|
||||
normalizeExe(info.basePrefix, windows)
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_RANK: Record<PythonOrigin["kind"], number> = {
|
||||
conda: 0,
|
||||
"python.org": 1,
|
||||
"windows-store": 2,
|
||||
venv: 3,
|
||||
system: 4,
|
||||
unknown: 5,
|
||||
};
|
||||
|
||||
function classifyOrigin(
|
||||
info: VerifyInfo,
|
||||
candidate: Candidate,
|
||||
conda: { root: string; envs: string[] },
|
||||
windows: boolean,
|
||||
): PythonOrigin {
|
||||
// Named envs live under the root too, so they must be matched first.
|
||||
for (const envPath of conda.envs) {
|
||||
if (envPath === conda.root) continue;
|
||||
if (pathContains(envPath, info.exe, windows))
|
||||
return { kind: "conda", envName: basename(envPath), root: envPath };
|
||||
}
|
||||
if (conda.root && pathContains(conda.root, info.exe, windows))
|
||||
return { kind: "conda", envName: "base", root: conda.root };
|
||||
if (isWindowsStorePackage(info.exe)) return { kind: "windows-store" };
|
||||
if (isVenv(info, windows))
|
||||
return {
|
||||
kind: "venv",
|
||||
baseExe: windows
|
||||
? join(info.basePrefix, "python.exe")
|
||||
: posixJoin(info.basePrefix, "bin", "python3"),
|
||||
};
|
||||
if ([...candidate.sources].some((source) => source.startsWith("registry")))
|
||||
return { kind: "python.org" };
|
||||
if (candidate.sources.has("PATH") || candidate.sources.has("which"))
|
||||
return { kind: "system" };
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
function compareEnvs(a: PythonEnv, b: PythonEnv): number {
|
||||
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
||||
const rank = KIND_RANK[a.origin.kind] - KIND_RANK[b.origin.kind];
|
||||
if (rank !== 0) return rank;
|
||||
const nameA = a.origin.kind === "conda" ? a.origin.envName : "";
|
||||
const nameB = b.origin.kind === "conda" ? b.origin.envName : "";
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB);
|
||||
return a.exe.localeCompare(b.exe);
|
||||
}
|
||||
|
||||
/** Prefer a named conda env, then any usable interpreter. */
|
||||
export function suggestInterpreter(envs: PythonEnv[]): string {
|
||||
const usable = envs.filter((env) => env.ok);
|
||||
const named = usable.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName !== "base",
|
||||
);
|
||||
return (named ?? usable[0])?.exe ?? "";
|
||||
}
|
||||
|
||||
async function verifyCandidates(
|
||||
candidates: Map<string, Candidate>,
|
||||
io: DetectIO,
|
||||
): Promise<Map<string, VerifyInfo>> {
|
||||
const verified = new Map<string, VerifyInfo>();
|
||||
const queue = [...candidates.entries()];
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
const [key, candidate] = entry;
|
||||
let result: RunResult;
|
||||
try {
|
||||
result = await io.run(candidate.exe, ["-c", VERIFY_SNIPPET], {
|
||||
timeoutMs: 8_000,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (result.error || result.code !== 0) continue;
|
||||
const info = parseVerifyOutput(result.stdout);
|
||||
if (!info) continue;
|
||||
// Prefer the interpreter's own sys.executable (resolves symlinks/shims).
|
||||
verified.set(key, { ...info, exe: info.exe || candidate.exe });
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(6, queue.length) }, () => worker()),
|
||||
);
|
||||
return verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local Python interpreters, verify each one by running it, and
|
||||
* classify origins. Discovery failures are collected; they never reject.
|
||||
*/
|
||||
export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
|
||||
const windows = io.platform === "win32";
|
||||
const errors: string[] = [];
|
||||
const candidates = new Map<string, Candidate>();
|
||||
let condaRoot = "";
|
||||
let condaEnvs: string[] = [];
|
||||
|
||||
const steps: Promise<void>[] = [
|
||||
// PEP 514 registry (Windows; Miniconda registers itself here too).
|
||||
(async () => {
|
||||
if (!windows) return;
|
||||
const hives = [
|
||||
"HKCU\\Software\\Python",
|
||||
"HKLM\\Software\\Python",
|
||||
"HKLM\\Software\\WOW6432Node\\Python",
|
||||
];
|
||||
for (const hive of hives) {
|
||||
const result = await io.run("reg.exe", ["query", hive, "/s", "/ve"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (result.error) continue;
|
||||
for (const { exe, source } of parseRegQuery(result.stdout))
|
||||
addCandidate(candidates, exe, source, windows);
|
||||
}
|
||||
})(),
|
||||
// py launcher (Windows only; usually absent without a python.org install).
|
||||
(async () => {
|
||||
if (!windows) return;
|
||||
const result = await io.run("py.exe", ["-0p"], { timeoutMs: 5_000 });
|
||||
if (result.error) return;
|
||||
for (const exe of parsePyListPaths(result.stdout))
|
||||
addCandidate(candidates, exe, "py launcher", windows);
|
||||
})(),
|
||||
// PATH / which.
|
||||
(async () => {
|
||||
const first = windows
|
||||
? await io.run("where.exe", ["python"], { timeoutMs: 5_000 })
|
||||
: await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 });
|
||||
let stdout = first.stdout;
|
||||
if (!windows) {
|
||||
const extra = await io.run("which", ["-a", "python"], {
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
stdout = `${stdout}\n${extra.stdout}`;
|
||||
}
|
||||
for (const exe of parsePathList(stdout)) {
|
||||
if (isWindowsAppsAlias(exe)) continue;
|
||||
addCandidate(candidates, exe, windows ? "PATH" : "which", windows);
|
||||
}
|
||||
})(),
|
||||
// conda: PATH first, then well-known install roots.
|
||||
(async () => {
|
||||
const located = windows
|
||||
? parsePathList(
|
||||
(await io.run("where.exe", ["conda"], { timeoutMs: 5_000 })).stdout,
|
||||
)
|
||||
: parsePathList(
|
||||
(await io.run("which", ["-a", "conda"], { timeoutMs: 5_000 }))
|
||||
.stdout,
|
||||
);
|
||||
for (const path of commonCondaPaths(io, windows))
|
||||
if (io.exists(path)) located.push(path);
|
||||
if (!located.length) return;
|
||||
const runConda = makeCondaRunner(
|
||||
io.run,
|
||||
located[0] ?? "",
|
||||
windows,
|
||||
io.exists,
|
||||
);
|
||||
const info = await runConda(["info", "--json"]);
|
||||
if (info.error)
|
||||
errors.push(
|
||||
`conda: discovered but not runnable (${safeError(info.error)})`,
|
||||
);
|
||||
condaRoot = info.error ? "" : parseCondaRoot(info.stdout);
|
||||
const listing = await runConda(["env", "list", "--json"]);
|
||||
condaEnvs = listing.error ? [] : parseCondaEnvs(listing.stdout);
|
||||
if (!condaRoot && condaEnvs.length) condaRoot = condaEnvs[0] ?? "";
|
||||
for (const envPath of condaEnvs) {
|
||||
addCandidate(
|
||||
candidates,
|
||||
windows
|
||||
? join(envPath, "python.exe")
|
||||
: posixJoin(envPath, "bin", "python3"),
|
||||
"conda env list",
|
||||
windows,
|
||||
);
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
errors.push(`conda: ${safeError(error)}`);
|
||||
}),
|
||||
];
|
||||
|
||||
await Promise.allSettled(steps);
|
||||
|
||||
const verified = await verifyCandidates(candidates, io);
|
||||
// Candidates can resolve to the same real executable (aliases, shims);
|
||||
// merge their discovery sources so the list stays duplicate-free.
|
||||
const merged = new Map<string, PythonEnv>();
|
||||
for (const [key, candidate] of candidates) {
|
||||
const info = verified.get(key);
|
||||
if (!info) continue;
|
||||
const exe = info.exe || candidate.exe;
|
||||
const dedupeKey = normalizeExe(exe, windows);
|
||||
const existing = merged.get(dedupeKey);
|
||||
if (existing) {
|
||||
existing.source = [
|
||||
...new Set([...existing.source.split("; "), ...candidate.sources]),
|
||||
]
|
||||
.sort()
|
||||
.join("; ");
|
||||
continue;
|
||||
}
|
||||
merged.set(dedupeKey, {
|
||||
exe,
|
||||
version: info.version,
|
||||
ok: versionAtLeast(info.version),
|
||||
origin: classifyOrigin(
|
||||
info,
|
||||
candidate,
|
||||
{ root: condaRoot, envs: condaEnvs },
|
||||
windows,
|
||||
),
|
||||
source: [...candidate.sources].sort().join("; "),
|
||||
});
|
||||
}
|
||||
const envs = [...merged.values()];
|
||||
envs.sort(compareEnvs);
|
||||
return {
|
||||
platform: io.platform,
|
||||
scannedAt: new Date().toISOString(),
|
||||
envs,
|
||||
suggested: suggestInterpreter(envs),
|
||||
condaRoot,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
75
integrations/dsh/src/pyenv/runner.ts
Normal file
75
integrations/dsh/src/pyenv/runner.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface RunResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
/** Spawn or timeout failure description; absent on a normal exit. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
timeoutMs?: number;
|
||||
cwd?: string;
|
||||
/** Join argv verbatim (cmd.exe /s /c wrappers); never combine with user input. */
|
||||
windowsVerbatimArguments?: boolean;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
exe: string,
|
||||
args: string[],
|
||||
options?: RunOptions,
|
||||
) => Promise<RunResult>;
|
||||
|
||||
/** UTF-8 guards so probe output survives GBK console codepages on Windows. */
|
||||
export function probeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...base,
|
||||
PYTHONIOENCODING: "utf-8",
|
||||
PYTHONUTF8: "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Array-argv, shell-free runner with a hard timeout and no thrown rejections. */
|
||||
export function createNodeRunner(
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): RunCommand {
|
||||
return (exe, args, options = {}) =>
|
||||
new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
...(options.windowsVerbatimArguments
|
||||
? { windowsVerbatimArguments: true }
|
||||
: {}),
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
env: probeEnv(base),
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({ code: null, stdout: "", stderr: "", error: String(error) });
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
}, options.timeoutMs ?? 10_000);
|
||||
timer.unref();
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: null, stdout, stderr, error: String(error) });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
32
integrations/dsh/src/pyenv/types.ts
Normal file
32
integrations/dsh/src/pyenv/types.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/** A local Python interpreter discovered on this machine. */
|
||||
export type PythonOrigin =
|
||||
| { kind: "conda"; envName: string; root: string }
|
||||
| { kind: "python.org" }
|
||||
| { kind: "windows-store" }
|
||||
| { kind: "venv"; baseExe: string }
|
||||
| { kind: "system" }
|
||||
| { kind: "unknown" };
|
||||
|
||||
export interface PythonEnv {
|
||||
/** Verified interpreter executable (absolute, host-native separators). */
|
||||
exe: string;
|
||||
/** "major.minor.patch" reported by the interpreter; empty when unusable. */
|
||||
version: string;
|
||||
/** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */
|
||||
ok: boolean;
|
||||
origin: PythonOrigin;
|
||||
/** Human-readable discovery channels, e.g. "conda env list; PATH". */
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface PythonDetection {
|
||||
platform: string;
|
||||
scannedAt: string;
|
||||
envs: PythonEnv[];
|
||||
/** Preferred interpreter for a local service, or "" when none is usable. */
|
||||
suggested: string;
|
||||
/** Conda installation root, or "" when conda was not found. */
|
||||
condaRoot: string;
|
||||
/** Non-fatal probe failures worth showing for transparency. */
|
||||
errors: string[];
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import type { ReMeClientConfig } from "./reme/types.js";
|
|||
|
||||
export interface ReMeConfigInput {
|
||||
endpoint?: string;
|
||||
authToken?: string;
|
||||
pythonPath?: string;
|
||||
requestTimeoutMs?: number;
|
||||
backgroundTimeoutMs?: number;
|
||||
shutdownTimeoutMs?: number;
|
||||
|
|
@ -22,6 +24,8 @@ export interface ReMeConfigInput {
|
|||
|
||||
export interface ReMeConfig extends ReMeClientConfig {
|
||||
shutdownTimeoutMs: number;
|
||||
authToken: string;
|
||||
pythonPath: string;
|
||||
autoMemoryEnabled: boolean;
|
||||
autoMemoryInterval: number;
|
||||
autoMemoryQueueDir: string;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ test("declares one installable DeepSeek Harness plugin", async () => {
|
|||
}
|
||||
assert.equal(manifest.peerDependencies.openclaw, undefined);
|
||||
assert.match(patch, /remeMemory: true/);
|
||||
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/);
|
||||
assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/);
|
||||
assert.doesNotMatch(patch, /id: reme-memory-runtime/);
|
||||
assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/);
|
||||
assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a
|
|||
fiber: { state: 0 },
|
||||
logger: { debug() {}, warn() {}, log() {} },
|
||||
provide(name, value) {
|
||||
assert.equal(name, "remeMemory");
|
||||
assert.ok(name === "remeMemory" || name === "remeEnv");
|
||||
assert.ok(value);
|
||||
},
|
||||
plugin() {
|
||||
|
|
|
|||
516
integrations/dsh/tests/env-detect.test.mjs
Normal file
516
integrations/dsh/tests/env-detect.test.mjs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
condaRootFromPath,
|
||||
detectPythonEnvs,
|
||||
isWindowsAppsAlias,
|
||||
makeCondaRunner,
|
||||
parseCondaEnvs,
|
||||
parseCondaRoot,
|
||||
parsePathList,
|
||||
parsePyListPaths,
|
||||
parseRegQuery,
|
||||
parseVerifyOutput,
|
||||
suggestInterpreter,
|
||||
versionAtLeast,
|
||||
} from "../dist/pyenv/detect.js";
|
||||
|
||||
function fakeRun(rules) {
|
||||
return async (exe, args) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.match(exe, args))
|
||||
return typeof rule.result === "function"
|
||||
? rule.result(args)
|
||||
: rule.result;
|
||||
}
|
||||
throw new Error(`unexpected command: ${exe} ${args.join(" ")}`);
|
||||
};
|
||||
}
|
||||
|
||||
const VERIFY_ARG0 = "-c";
|
||||
|
||||
function verifyRule(exe, version, base) {
|
||||
return {
|
||||
match: (actual, args) =>
|
||||
actual.toLowerCase() === exe.toLowerCase() && args[0] === VERIFY_ARG0,
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: `note\n{"exe":"${exe.replaceAll(
|
||||
"\\",
|
||||
"\\\\",
|
||||
)}","ver":"${version}","base":"${base.replaceAll("\\", "\\\\")}"}\n`,
|
||||
stderr: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function windowsFixture() {
|
||||
const miniconda = "D:\\ProgramFils\\Miniconda";
|
||||
const remeServe = `${miniconda}\\envs\\reme-serve`;
|
||||
const old38 = `${miniconda}\\envs\\old38`;
|
||||
const storePackage =
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0";
|
||||
const rules = [
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "reg.exe" &&
|
||||
args[0] === "query" &&
|
||||
args[1] === "HKCU\\Software\\Python",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: [
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12",
|
||||
" (默认) REG_SZ 3.12",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath`,
|
||||
` (默认) REG_SZ ${miniconda}\\`,
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12\\InstallPath`,
|
||||
" (Default) REG_SZ C:\\Python312\\",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonLegacy\\3.8\\InstallPath`,
|
||||
" (Default) REG_SZ C:\\Python38",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11",
|
||||
"",
|
||||
`HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11\\InstallPath`,
|
||||
` (Default) REG_SZ ${storePackage}\\`,
|
||||
"",
|
||||
].join("\r\n"),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "reg.exe" && args[0] === "query",
|
||||
result: { code: 1, stdout: "", stderr: "key not found" },
|
||||
},
|
||||
{
|
||||
match: (exe) => exe === "py.exe",
|
||||
result: {
|
||||
code: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: "spawn py.exe ENOENT",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "where.exe" && args[0] === "python",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: [
|
||||
miniconda + "\\python.exe",
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) => exe === "where.exe" && args[0] === "conda",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: `${miniconda}\\Library\\bin\\conda.bat\r\n`,
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === `${miniconda}\\Scripts\\conda.exe` &&
|
||||
args[0] === "info" &&
|
||||
args[1] === "--json",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ root_prefix: miniconda }),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === `${miniconda}\\Scripts\\conda.exe` &&
|
||||
args[0] === "env" &&
|
||||
args[1] === "list",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
envs: [miniconda, remeServe, old38],
|
||||
}),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule(`${miniconda}\\python.exe`, "3.12.3", miniconda),
|
||||
verifyRule(`${remeServe}\\python.exe`, "3.12.13", remeServe),
|
||||
verifyRule(`${old38}\\python.exe`, "3.8.10", old38),
|
||||
verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"),
|
||||
verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"),
|
||||
verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage),
|
||||
];
|
||||
return {
|
||||
rules,
|
||||
miniconda,
|
||||
remeServe,
|
||||
old38,
|
||||
storePackage,
|
||||
io: {
|
||||
run: fakeRun(rules),
|
||||
platform: "win32",
|
||||
env: { USERPROFILE: "C:\\Users\\u", ProgramData: "C:\\ProgramData" },
|
||||
homedir: "C:\\Users\\u",
|
||||
exists: (path) => path === `${miniconda}\\Scripts\\conda.exe`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("parses PEP 514 registry output across locales, mojibake and empty values", () => {
|
||||
const found = parseRegQuery(
|
||||
[
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
|
||||
" (\uFFFD\uFFFD) REG_SZ D:\\ProgramFils\\Miniconda\\",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.11\\InstallPath",
|
||||
" (Default) REG_SZ C:\\Python311",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\Localized\\3.10\\InstallPath",
|
||||
" (默认) REG_SZ C:\\Python310",
|
||||
"",
|
||||
"HKEY_CURRENT_USER\\Software\\Python\\Empty\\InstallPath",
|
||||
" (\uFFFD\uFFFD) REG_SZ (\uFFFD\uFFFD\uFFFD\uFFFD)",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
found.map((entry) => entry.exe),
|
||||
[
|
||||
"D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
"C:\\Python311\\python.exe",
|
||||
"C:\\Python310\\python.exe",
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
found[0].source,
|
||||
"registry HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses py launcher and path listings", () => {
|
||||
assert.deepEqual(
|
||||
parsePyListPaths(
|
||||
[
|
||||
" -V:3.12 * D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
" -V:3.11 C:\\Python311\\python.exe",
|
||||
" -V:2.7 C:\\Python27\\python.exe",
|
||||
].join("\n"),
|
||||
),
|
||||
[
|
||||
"D:\\ProgramFils\\Miniconda\\python.exe",
|
||||
"C:\\Python311\\python.exe",
|
||||
"C:\\Python27\\python.exe",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(parsePathList("a\\python.exe\r\n\r\nb\\python.exe\n"), [
|
||||
"a\\python.exe",
|
||||
"b\\python.exe",
|
||||
]);
|
||||
});
|
||||
|
||||
test("drops Store alias stubs but keeps real Store interpreters", () => {
|
||||
assert.equal(
|
||||
isWindowsAppsAlias(
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python3.exe",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWindowsAppsAlias(
|
||||
"C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\\python.exe",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isWindowsAppsAlias("C:\\Python312\\python.exe"), false);
|
||||
});
|
||||
|
||||
test("compares interpreter versions numerically", () => {
|
||||
assert.equal(versionAtLeast("3.12.3"), true);
|
||||
assert.equal(versionAtLeast("3.11.0"), true);
|
||||
assert.equal(versionAtLeast("3.12"), true);
|
||||
assert.equal(versionAtLeast("3.10.8"), false);
|
||||
assert.equal(versionAtLeast("3.8"), false);
|
||||
assert.equal(versionAtLeast(""), false);
|
||||
assert.equal(versionAtLeast("abc"), false);
|
||||
});
|
||||
|
||||
test("parses interpreter verify output embedded in noisy stdout", () => {
|
||||
const info = parseVerifyOutput(
|
||||
'Warning: something\n{"exe":"D:\\\\x\\\\python.exe","ver":"3.12.3","base":"D:\\\\x"}\n',
|
||||
);
|
||||
assert.deepEqual(info, {
|
||||
exe: "D:\\x\\python.exe",
|
||||
version: "3.12.3",
|
||||
basePrefix: "D:\\x",
|
||||
});
|
||||
assert.equal(parseVerifyOutput("no json here"), undefined);
|
||||
});
|
||||
|
||||
test("parses conda json output defensively", () => {
|
||||
assert.deepEqual(parseCondaEnvs('{"envs":["D:\\\\a","D:\\\\b"]}'), [
|
||||
"D:\\a",
|
||||
"D:\\b",
|
||||
]);
|
||||
assert.deepEqual(parseCondaEnvs("not json"), []);
|
||||
assert.deepEqual(parseCondaEnvs('{"envs":42}'), []);
|
||||
assert.equal(parseCondaRoot('{"root_prefix":"D:\\\\a"}'), "D:\\a");
|
||||
assert.equal(parseCondaRoot("garbage"), "");
|
||||
});
|
||||
|
||||
test("derives conda roots and prefers the real conda.exe over the .bat", async () => {
|
||||
assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc");
|
||||
assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc");
|
||||
assert.equal(
|
||||
condaRootFromPath("/home/u/miniconda3/bin/conda"),
|
||||
"/home/u/miniconda3",
|
||||
);
|
||||
assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), "");
|
||||
|
||||
const calls = [];
|
||||
const run = async (exe, args) => {
|
||||
calls.push([exe, args]);
|
||||
return { code: 0, stdout: "{}", stderr: "" };
|
||||
};
|
||||
const runner = makeCondaRunner(
|
||||
run,
|
||||
"D:\\mc\\Library\\bin\\conda.bat",
|
||||
true,
|
||||
(path) => path === "D:\\mc\\Scripts\\conda.exe",
|
||||
);
|
||||
await runner(["info", "--json"]);
|
||||
assert.deepEqual(calls[0][0], "D:\\mc\\Scripts\\conda.exe");
|
||||
|
||||
const fallback = [];
|
||||
const runner2 = makeCondaRunner(
|
||||
async (exe, args) => {
|
||||
fallback.push([exe, args]);
|
||||
return { code: null, stdout: "", stderr: "", error: "spawn ENOENT" };
|
||||
},
|
||||
"D:\\mc\\Library\\bin\\conda.bat",
|
||||
true,
|
||||
() => false,
|
||||
);
|
||||
await runner2(["env", "list", "--json"]);
|
||||
assert.equal(fallback.at(-1)[0], "cmd.exe");
|
||||
assert.ok(fallback.at(-1)[1].includes("/s"));
|
||||
});
|
||||
|
||||
test("detects, classifies and suggests Windows interpreters end to end", async () => {
|
||||
const { io, miniconda, remeServe, old38, storePackage } = windowsFixture();
|
||||
const detection = await detectPythonEnvs(io);
|
||||
|
||||
assert.equal(detection.platform, "win32");
|
||||
assert.equal(detection.condaRoot, miniconda);
|
||||
assert.deepEqual(detection.errors, []);
|
||||
assert.equal(detection.suggested, `${remeServe}\\python.exe`);
|
||||
|
||||
const exes = detection.envs.map((env) => env.exe.toLowerCase());
|
||||
assert.ok(!exes.some((exe) => exe.includes("windowsapps\\\\python.exe")));
|
||||
assert.ok(
|
||||
!exes.some(
|
||||
(exe) =>
|
||||
exe ===
|
||||
"c:\\users\\u\\appdata\\local\\microsoft\\windowsapps\\python.exe",
|
||||
),
|
||||
);
|
||||
assert.equal(detection.envs.length, 6);
|
||||
|
||||
const base = detection.envs.find(
|
||||
(env) => env.origin.kind === "conda" && env.origin.envName === "base",
|
||||
);
|
||||
assert.ok(base);
|
||||
assert.equal(base.exe, `${miniconda}\\python.exe`);
|
||||
assert.equal(base.version, "3.12.3");
|
||||
assert.equal(base.ok, true);
|
||||
assert.equal(base.origin.root, miniconda);
|
||||
assert.ok(base.source.includes("PATH"));
|
||||
assert.ok(base.source.includes("conda env list"));
|
||||
assert.ok(base.source.includes("registry"));
|
||||
|
||||
const named = detection.envs.find(
|
||||
(env) => env.exe === `${remeServe}\\python.exe`,
|
||||
);
|
||||
assert.ok(named);
|
||||
assert.equal(named.origin.kind, "conda");
|
||||
assert.equal(named.origin.envName, "reme-serve");
|
||||
|
||||
const legacy = detection.envs.find(
|
||||
(env) => env.exe === `${old38}\\python.exe`,
|
||||
);
|
||||
assert.ok(legacy);
|
||||
assert.equal(legacy.ok, false);
|
||||
assert.ok(detection.envs.indexOf(legacy) > detection.envs.indexOf(base));
|
||||
|
||||
const pythonOrg = detection.envs.find(
|
||||
(env) => env.exe === "C:\\Python312\\python.exe",
|
||||
);
|
||||
assert.ok(pythonOrg);
|
||||
assert.equal(pythonOrg.origin.kind, "python.org");
|
||||
|
||||
const legacy38 = detection.envs.find(
|
||||
(env) => env.exe === "C:\\Python38\\python.exe",
|
||||
);
|
||||
assert.ok(legacy38);
|
||||
assert.equal(legacy38.ok, false);
|
||||
|
||||
const store = detection.envs.find(
|
||||
(env) => env.exe === `${storePackage}\\python.exe`,
|
||||
);
|
||||
assert.ok(store);
|
||||
assert.equal(store.origin.kind, "windows-store");
|
||||
assert.equal(store.ok, true);
|
||||
});
|
||||
|
||||
test("suggests nothing usable by preferring named conda envs then any usable interpreter", () => {
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "base", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
{
|
||||
exe: "b",
|
||||
version: "3.12.0",
|
||||
ok: true,
|
||||
origin: { kind: "conda", envName: "reme", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
]),
|
||||
"b",
|
||||
);
|
||||
assert.equal(
|
||||
suggestInterpreter([
|
||||
{
|
||||
exe: "a",
|
||||
version: "3.8.0",
|
||||
ok: false,
|
||||
origin: { kind: "conda", envName: "base", root: "r" },
|
||||
source: "",
|
||||
},
|
||||
{
|
||||
exe: "c",
|
||||
version: "3.11.9",
|
||||
ok: true,
|
||||
origin: { kind: "python.org" },
|
||||
source: "",
|
||||
},
|
||||
]),
|
||||
"c",
|
||||
);
|
||||
assert.equal(suggestInterpreter([]), "");
|
||||
});
|
||||
|
||||
test("detects Unix interpreters and merges paths that resolve together", async () => {
|
||||
const rules = [
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "python3",
|
||||
result: { code: 0, stdout: "/usr/bin/python3\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "python",
|
||||
result: { code: 0, stdout: "/usr/bin/python\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "which" && args[0] === "-a" && args[1] === "conda",
|
||||
result: { code: 0, stdout: "/home/u/miniconda3/bin/conda\n", stderr: "" },
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "/home/u/miniconda3/bin/conda" && args[0] === "info",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ root_prefix: "/home/u/miniconda3" }),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
match: (exe, args) =>
|
||||
exe === "/home/u/miniconda3/bin/conda" && args[0] === "env",
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
envs: ["/home/u/miniconda3", "/home/u/miniconda3/envs/reme"],
|
||||
}),
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule("/usr/bin/python3", "3.10.12", "/usr"),
|
||||
// /usr/bin/python is a shim whose sys.executable reports python3.
|
||||
{
|
||||
match: (actual, args) =>
|
||||
actual === "/usr/bin/python" && args[0] === VERIFY_ARG0,
|
||||
result: {
|
||||
code: 0,
|
||||
stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n',
|
||||
stderr: "",
|
||||
},
|
||||
},
|
||||
verifyRule(
|
||||
"/home/u/miniconda3/bin/python3",
|
||||
"3.12.1",
|
||||
"/home/u/miniconda3",
|
||||
),
|
||||
verifyRule(
|
||||
"/home/u/miniconda3/envs/reme/bin/python3",
|
||||
"3.11.9",
|
||||
"/home/u/miniconda3/envs/reme",
|
||||
),
|
||||
];
|
||||
const detection = await detectPythonEnvs({
|
||||
run: fakeRun(rules),
|
||||
platform: "linux",
|
||||
env: {},
|
||||
homedir: "/home/u",
|
||||
exists: () => false,
|
||||
});
|
||||
assert.equal(detection.platform, "linux");
|
||||
assert.equal(detection.condaRoot, "/home/u/miniconda3");
|
||||
assert.equal(detection.suggested, "/home/u/miniconda3/envs/reme/bin/python3");
|
||||
|
||||
const system = detection.envs.filter((env) => env.origin.kind === "system");
|
||||
assert.equal(system.length, 1);
|
||||
assert.equal(system[0].exe, "/usr/bin/python3");
|
||||
assert.equal(system[0].ok, false);
|
||||
|
||||
const named = detection.envs.find((env) => env.origin.envName === "reme");
|
||||
assert.ok(named);
|
||||
assert.equal(named.ok, true);
|
||||
assert.equal(named.version, "3.11.9");
|
||||
});
|
||||
|
||||
test("collects conda failures without rejecting the whole detection", async () => {
|
||||
const { io, miniconda } = windowsFixture();
|
||||
io.run = async (exe, args) => {
|
||||
if (exe === `${miniconda}\\Scripts\\conda.exe`)
|
||||
return { code: null, stdout: "", stderr: "", error: "spawn failed" };
|
||||
const original = windowsFixture();
|
||||
const rule = original.rules.find((entry) => entry.match(exe, args));
|
||||
if (!rule) throw new Error(`unexpected: ${exe} ${args.join(" ")}`);
|
||||
return typeof rule.result === "function" ? rule.result(args) : rule.result;
|
||||
};
|
||||
const detection = await detectPythonEnvs(io);
|
||||
assert.equal(detection.condaRoot, "");
|
||||
assert.ok(detection.errors.some((error) => error.startsWith("conda:")));
|
||||
assert.ok(
|
||||
detection.envs.some((env) => env.exe === `${miniconda}\\python.exe`),
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue