fix: resolve gcloud python path
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
||||||
|
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
|
||||||
- Agent tools: scope the Discord tool to Discord surface runs.
|
- Agent tools: scope the Discord tool to Discord surface runs.
|
||||||
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
||||||
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
|
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
|
||||||
|
|||||||
47
src/hooks/gmail-setup-utils.test.ts
Normal file
47
src/hooks/gmail-setup-utils.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePythonExecutablePath", () => {
|
||||||
|
it("resolves a working python path and caches the result", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-python-"));
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
try {
|
||||||
|
const realPython = path.join(tmp, "python-real");
|
||||||
|
await fs.writeFile(realPython, "#!/bin/sh\nexit 0\n", "utf-8");
|
||||||
|
await fs.chmod(realPython, 0o755);
|
||||||
|
|
||||||
|
const shimDir = path.join(tmp, "shims");
|
||||||
|
await fs.mkdir(shimDir, { recursive: true });
|
||||||
|
const shim = path.join(shimDir, "python3");
|
||||||
|
await fs.writeFile(
|
||||||
|
shim,
|
||||||
|
`#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then\n echo \"${realPython}\"\n exit 0\nfi\nexit 1\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
await fs.chmod(shim, 0o755);
|
||||||
|
|
||||||
|
process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`;
|
||||||
|
|
||||||
|
const { resolvePythonExecutablePath } = await import(
|
||||||
|
"./gmail-setup-utils.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = await resolvePythonExecutablePath();
|
||||||
|
expect(resolved).toBe(realPython);
|
||||||
|
|
||||||
|
process.env.PATH = "/bin";
|
||||||
|
const cached = await resolvePythonExecutablePath();
|
||||||
|
expect(cached).toBe(realPython);
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,106 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
|||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { normalizeServePath } from "./gmail.js";
|
import { normalizeServePath } from "./gmail.js";
|
||||||
|
|
||||||
|
let cachedPythonPath: string | null | undefined;
|
||||||
|
|
||||||
|
function findExecutablesOnPath(bins: string[]): string[] {
|
||||||
|
const pathEnv = process.env.PATH ?? "";
|
||||||
|
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const matches: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const bin of bins) {
|
||||||
|
const candidate = path.join(part, bin);
|
||||||
|
if (seen.has(candidate)) continue;
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
matches.push(candidate);
|
||||||
|
seen.add(candidate);
|
||||||
|
} catch {
|
||||||
|
// keep scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePathIncludes(dirPath: string, position: "append" | "prepend") {
|
||||||
|
const pathEnv = process.env.PATH ?? "";
|
||||||
|
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||||
|
if (parts.includes(dirPath)) return;
|
||||||
|
const next =
|
||||||
|
position === "prepend" ? [dirPath, ...parts] : [...parts, dirPath];
|
||||||
|
process.env.PATH = next.join(path.delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGcloudOnPath(): boolean {
|
||||||
|
if (hasBinary("gcloud")) return true;
|
||||||
|
const candidates = [
|
||||||
|
"/opt/homebrew/share/google-cloud-sdk/bin/gcloud",
|
||||||
|
"/usr/local/share/google-cloud-sdk/bin/gcloud",
|
||||||
|
"/opt/homebrew/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud",
|
||||||
|
"/usr/local/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud",
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
ensurePathIncludes(path.dirname(candidate), "append");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// keep scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePythonExecutablePath(): Promise<string | undefined> {
|
||||||
|
if (cachedPythonPath !== undefined) {
|
||||||
|
return cachedPythonPath ?? undefined;
|
||||||
|
}
|
||||||
|
const candidates = findExecutablesOnPath(["python3", "python"]);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const res = await runCommandWithTimeout(
|
||||||
|
[
|
||||||
|
candidate,
|
||||||
|
"-c",
|
||||||
|
"import os, sys; print(os.path.realpath(sys.executable))",
|
||||||
|
],
|
||||||
|
{ timeoutMs: 2_000 },
|
||||||
|
);
|
||||||
|
if (res.code !== 0) continue;
|
||||||
|
const resolved = res.stdout.trim().split(/\s+/)[0];
|
||||||
|
if (!resolved) continue;
|
||||||
|
try {
|
||||||
|
fs.accessSync(resolved, fs.constants.X_OK);
|
||||||
|
cachedPythonPath = resolved;
|
||||||
|
return resolved;
|
||||||
|
} catch {
|
||||||
|
// keep scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedPythonPath = null;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gcloudEnv(): Promise<NodeJS.ProcessEnv | undefined> {
|
||||||
|
if (process.env.CLOUDSDK_PYTHON) return undefined;
|
||||||
|
const pythonPath = await resolvePythonExecutablePath();
|
||||||
|
if (!pythonPath) return undefined;
|
||||||
|
return { CLOUDSDK_PYTHON: pythonPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGcloudCommand(
|
||||||
|
args: string[],
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Awaited<ReturnType<typeof runCommandWithTimeout>>> {
|
||||||
|
return await runCommandWithTimeout(["gcloud", ...args], {
|
||||||
|
timeoutMs,
|
||||||
|
env: await gcloudEnv(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureDependency(bin: string, brewArgs: string[]) {
|
export async function ensureDependency(bin: string, brewArgs: string[]) {
|
||||||
|
if (bin === "gcloud" && ensureGcloudOnPath()) return;
|
||||||
if (hasBinary(bin)) return;
|
if (hasBinary(bin)) return;
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
throw new Error(`${bin} not installed; install it and retry`);
|
throw new Error(`${bin} not installed; install it and retry`);
|
||||||
@@ -14,8 +113,10 @@ export async function ensureDependency(bin: string, brewArgs: string[]) {
|
|||||||
if (!hasBinary("brew")) {
|
if (!hasBinary("brew")) {
|
||||||
throw new Error("Homebrew not installed (install brew and retry)");
|
throw new Error("Homebrew not installed (install brew and retry)");
|
||||||
}
|
}
|
||||||
|
const brewEnv = bin === "gcloud" ? await gcloudEnv() : undefined;
|
||||||
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
|
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
|
||||||
timeoutMs: 600_000,
|
timeoutMs: 600_000,
|
||||||
|
env: brewEnv,
|
||||||
});
|
});
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -28,31 +129,19 @@ export async function ensureDependency(bin: string, brewArgs: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureGcloudAuth() {
|
export async function ensureGcloudAuth() {
|
||||||
const res = await runCommandWithTimeout(
|
const res = await runGcloudCommand(
|
||||||
[
|
["auth", "list", "--filter", "status:ACTIVE", "--format", "value(account)"],
|
||||||
"gcloud",
|
30_000,
|
||||||
"auth",
|
|
||||||
"list",
|
|
||||||
"--filter",
|
|
||||||
"status:ACTIVE",
|
|
||||||
"--format",
|
|
||||||
"value(account)",
|
|
||||||
],
|
|
||||||
{ timeoutMs: 30_000 },
|
|
||||||
);
|
);
|
||||||
if (res.code === 0 && res.stdout.trim()) return;
|
if (res.code === 0 && res.stdout.trim()) return;
|
||||||
const login = await runCommandWithTimeout(["gcloud", "auth", "login"], {
|
const login = await runGcloudCommand(["auth", "login"], 600_000);
|
||||||
timeoutMs: 600_000,
|
|
||||||
});
|
|
||||||
if (login.code !== 0) {
|
if (login.code !== 0) {
|
||||||
throw new Error(login.stderr || "gcloud auth login failed");
|
throw new Error(login.stderr || "gcloud auth login failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runGcloud(args: string[]) {
|
export async function runGcloud(args: string[]) {
|
||||||
const result = await runCommandWithTimeout(["gcloud", ...args], {
|
const result = await runGcloudCommand(args, 120_000);
|
||||||
timeoutMs: 120_000,
|
|
||||||
});
|
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
throw new Error(result.stderr || result.stdout || "gcloud command failed");
|
throw new Error(result.stderr || result.stdout || "gcloud command failed");
|
||||||
}
|
}
|
||||||
@@ -60,17 +149,9 @@ export async function runGcloud(args: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureTopic(projectId: string, topicName: string) {
|
export async function ensureTopic(projectId: string, topicName: string) {
|
||||||
const describe = await runCommandWithTimeout(
|
const describe = await runGcloudCommand(
|
||||||
[
|
["pubsub", "topics", "describe", topicName, "--project", projectId],
|
||||||
"gcloud",
|
30_000,
|
||||||
"pubsub",
|
|
||||||
"topics",
|
|
||||||
"describe",
|
|
||||||
topicName,
|
|
||||||
"--project",
|
|
||||||
projectId,
|
|
||||||
],
|
|
||||||
{ timeoutMs: 30_000 },
|
|
||||||
);
|
);
|
||||||
if (describe.code === 0) return;
|
if (describe.code === 0) return;
|
||||||
await runGcloud([
|
await runGcloud([
|
||||||
@@ -89,17 +170,9 @@ export async function ensureSubscription(
|
|||||||
topicName: string,
|
topicName: string,
|
||||||
pushEndpoint: string,
|
pushEndpoint: string,
|
||||||
) {
|
) {
|
||||||
const describe = await runCommandWithTimeout(
|
const describe = await runGcloudCommand(
|
||||||
[
|
["pubsub", "subscriptions", "describe", subscription, "--project", projectId],
|
||||||
"gcloud",
|
30_000,
|
||||||
"pubsub",
|
|
||||||
"subscriptions",
|
|
||||||
"describe",
|
|
||||||
subscription,
|
|
||||||
"--project",
|
|
||||||
projectId,
|
|
||||||
],
|
|
||||||
{ timeoutMs: 30_000 },
|
|
||||||
);
|
);
|
||||||
if (describe.code === 0) {
|
if (describe.code === 0) {
|
||||||
await runGcloud([
|
await runGcloud([
|
||||||
@@ -188,9 +261,8 @@ export async function resolveProjectIdFromGogCredentials(): Promise<
|
|||||||
const clientId = extractGogClientId(parsed);
|
const clientId = extractGogClientId(parsed);
|
||||||
const projectNumber = extractProjectNumber(clientId);
|
const projectNumber = extractProjectNumber(clientId);
|
||||||
if (!projectNumber) continue;
|
if (!projectNumber) continue;
|
||||||
const res = await runCommandWithTimeout(
|
const res = await runGcloudCommand(
|
||||||
[
|
[
|
||||||
"gcloud",
|
|
||||||
"projects",
|
"projects",
|
||||||
"list",
|
"list",
|
||||||
"--filter",
|
"--filter",
|
||||||
@@ -198,7 +270,7 @@ export async function resolveProjectIdFromGogCredentials(): Promise<
|
|||||||
"--format",
|
"--format",
|
||||||
"value(projectId)",
|
"value(projectId)",
|
||||||
],
|
],
|
||||||
{ timeoutMs: 30_000 },
|
30_000,
|
||||||
);
|
);
|
||||||
if (res.code !== 0) continue;
|
if (res.code !== 0) continue;
|
||||||
const projectId = res.stdout.trim().split(/\s+/)[0];
|
const projectId = res.stdout.trim().split(/\s+/)[0];
|
||||||
|
|||||||
@@ -60,4 +60,55 @@ describe("ensureClawdisCliOnPath", () => {
|
|||||||
else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag;
|
else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prepends mise shims when available", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-path-"));
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
const originalFlag = process.env.CLAWDIS_PATH_BOOTSTRAPPED;
|
||||||
|
const originalMiseDataDir = process.env.MISE_DATA_DIR;
|
||||||
|
try {
|
||||||
|
const relayDir = path.join(tmp, "Relay");
|
||||||
|
await fs.mkdir(relayDir, { recursive: true });
|
||||||
|
const relayCli = path.join(relayDir, "clawdis");
|
||||||
|
await fs.writeFile(relayCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||||
|
await fs.chmod(relayCli, 0o755);
|
||||||
|
|
||||||
|
const localBinDir = path.join(tmp, "node_modules", ".bin");
|
||||||
|
await fs.mkdir(localBinDir, { recursive: true });
|
||||||
|
const localCli = path.join(localBinDir, "clawdis");
|
||||||
|
await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||||
|
await fs.chmod(localCli, 0o755);
|
||||||
|
|
||||||
|
const miseDataDir = path.join(tmp, "mise");
|
||||||
|
const shimsDir = path.join(miseDataDir, "shims");
|
||||||
|
await fs.mkdir(shimsDir, { recursive: true });
|
||||||
|
process.env.MISE_DATA_DIR = miseDataDir;
|
||||||
|
process.env.PATH = "/usr/bin";
|
||||||
|
delete process.env.CLAWDIS_PATH_BOOTSTRAPPED;
|
||||||
|
|
||||||
|
ensureClawdisCliOnPath({
|
||||||
|
execPath: relayCli,
|
||||||
|
cwd: tmp,
|
||||||
|
homeDir: tmp,
|
||||||
|
platform: "darwin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = process.env.PATH ?? "";
|
||||||
|
const parts = updated.split(path.delimiter);
|
||||||
|
const relayIndex = parts.indexOf(relayDir);
|
||||||
|
const localIndex = parts.indexOf(localBinDir);
|
||||||
|
const shimsIndex = parts.indexOf(shimsDir);
|
||||||
|
expect(relayIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(localIndex).toBeGreaterThan(relayIndex);
|
||||||
|
expect(shimsIndex).toBeGreaterThan(localIndex);
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
if (originalFlag === undefined)
|
||||||
|
delete process.env.CLAWDIS_PATH_BOOTSTRAPPED;
|
||||||
|
else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag;
|
||||||
|
if (originalMiseDataDir === undefined) delete process.env.MISE_DATA_DIR;
|
||||||
|
else process.env.MISE_DATA_DIR = originalMiseDataDir;
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ function candidateBinDirs(opts: EnsureClawdisPathOpts): string[] {
|
|||||||
if (isExecutable(path.join(localBinDir, "clawdis")))
|
if (isExecutable(path.join(localBinDir, "clawdis")))
|
||||||
candidates.push(localBinDir);
|
candidates.push(localBinDir);
|
||||||
|
|
||||||
|
const miseDataDir =
|
||||||
|
process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
|
||||||
|
const miseShims = path.join(miseDataDir, "shims");
|
||||||
|
if (isDirectory(miseShims)) candidates.push(miseShims);
|
||||||
|
|
||||||
// Common global install locations (macOS first).
|
// Common global install locations (macOS first).
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
candidates.push(path.join(homeDir, "Library", "pnpm"));
|
candidates.push(path.join(homeDir, "Library", "pnpm"));
|
||||||
|
|||||||
Reference in New Issue
Block a user