openclaw / src /hooks /gmail-setup-utils.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
d197cf3 verified
Raw History Blame Contribute Delete
11.3 kB
// Gmail setup utilities write helper files and normalize Gmail setup settings.
import fs from "node:fs";
import path from "node:path";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveExecutable } from "../infra/executable-path.js";
import { formatCommandOutput, formatCommandResult } from "../process/command-error.js";
import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js";
import { hasBinary } from "../skills/loading/config.js";
import { resolveUserPath } from "../utils.js";
import { normalizeServePath } from "./gmail.js";
let cachedPythonPath: string | null | undefined;
let gcloudBin: string | undefined;
function formatJsonParseFailure(command: string, result: SpawnResult, err: unknown): string {
const reason = formatCommandOutput(formatErrorMessage(err));
return `${command} returned invalid JSON: ${reason}\n${formatCommandResult(command, result)}`;
}
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;
}
// gcloud requires a Python interpreter in this range to run; picking an
// interpreter outside it makes `gcloud` fail to load. See `gcloud topic startup`.
const MIN_GCLOUD_PYTHON: readonly [number, number] = [3, 10];
const MAX_GCLOUD_PYTHON: readonly [number, number] = [3, 14];
function isSupportedGcloudPythonVersion(major: number, minor: number): boolean {
if (major !== MIN_GCLOUD_PYTHON[0]) {
return false;
}
return minor >= MIN_GCLOUD_PYTHON[1] && minor <= MAX_GCLOUD_PYTHON[1];
}
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)); print('%d.%d' % sys.version_info[:2])",
],
{ timeoutMs: 2_000 },
);
if (res.code !== 0) {
continue;
}
const lines = res.stdout.trim().split(/\r?\n/);
const resolved = lines[0]?.trim().split(/\s+/)[0];
if (!resolved) {
continue;
}
const version = lines[1]?.trim().match(/^(\d+)\.(\d+)/);
if (!version) {
continue;
}
if (!isSupportedGcloudPythonVersion(Number(version[1]), Number(version[2]))) {
// Skip interpreters gcloud cannot use (e.g. macOS' bundled Python 3.9)
// so a compatible interpreter later on PATH is selected instead.
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> {
const pythonPath = await resolvePythonExecutablePath();
// Always override inherited gcloud Python controls so the launcher cannot
// select a workspace-controlled interpreter or word-split injected args.
return { CLOUDSDK_PYTHON: pythonPath, CLOUDSDK_PYTHON_ARGS: undefined };
}
async function runGcloudCommand(
args: string[],
timeoutMs: number,
): Promise<Awaited<ReturnType<typeof runCommandWithTimeout>>> {
return await runCommandWithTimeout([(gcloudBin ??= resolveExecutable("gcloud")), ...args], {
timeoutMs,
env: await gcloudEnv(),
});
}
export async function ensureDependency(bin: string, brewArgs: string[]) {
if (bin === "gcloud" && ensureGcloudOnPath()) {
return;
}
if (hasBinary(bin)) {
return;
}
if (process.platform !== "darwin") {
throw new Error(`${bin} not installed; install it and retry`);
}
if (!hasBinary("brew")) {
throw new Error("Homebrew not installed (install brew and retry)");
}
const brewEnv = bin === "gcloud" ? await gcloudEnv() : undefined;
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
timeoutMs: 600_000,
env: brewEnv,
});
if (result.code !== 0) {
throw new Error(formatCommandResult(`brew install for ${bin}`, result));
}
if (!hasBinary(bin)) {
throw new Error(`${bin} still not available after brew install`);
}
}
export async function ensureGcloudAuth() {
const res = await runGcloudCommand(
["auth", "list", "--filter", "status:ACTIVE", "--format", "value(account)"],
30_000,
);
if (res.code === 0 && res.stdout.trim()) {
return;
}
const login = await runGcloudCommand(["auth", "login"], 600_000);
if (login.code !== 0) {
throw new Error(formatCommandResult("gcloud auth login", login));
}
}
export async function runGcloud(args: string[]) {
const result = await runGcloudCommand(args, 120_000);
if (result.code !== 0) {
throw new Error(formatCommandResult("gcloud command", result));
}
return result;
}
export async function ensureTopic(projectId: string, topicName: string) {
const describe = await runGcloudCommand(
["pubsub", "topics", "describe", topicName, "--project", projectId],
30_000,
);
if (describe.code === 0) {
return;
}
await runGcloud(["pubsub", "topics", "create", topicName, "--project", projectId]);
}
export async function ensureSubscription(
projectId: string,
subscription: string,
topicName: string,
pushEndpoint: string,
) {
const describe = await runGcloudCommand(
["pubsub", "subscriptions", "describe", subscription, "--project", projectId],
30_000,
);
if (describe.code === 0) {
await runGcloud([
"pubsub",
"subscriptions",
"update",
subscription,
"--project",
projectId,
"--push-endpoint",
pushEndpoint,
]);
return;
}
await runGcloud([
"pubsub",
"subscriptions",
"create",
subscription,
"--project",
projectId,
"--topic",
topicName,
"--push-endpoint",
pushEndpoint,
]);
}
export async function ensureTailscaleEndpoint(params: {
mode: "off" | "serve" | "funnel";
path: string;
port?: number;
signal?: AbortSignal;
target?: string;
token?: string;
}): Promise<string> {
if (params.mode === "off") {
return "";
}
const tailscaleBin = resolveExecutable("tailscale");
const statusArgs = ["status", "--json"];
const statusCommand = "tailscale status --json";
const status = await runCommandWithTimeout([tailscaleBin, ...statusArgs], {
timeoutMs: 30_000,
signal: params.signal,
});
if (status.code !== 0) {
throw new Error(formatCommandResult(statusCommand, status));
}
let parsed: { Self?: { DNSName?: string } };
try {
parsed = JSON.parse(status.stdout) as { Self?: { DNSName?: string } };
} catch (err) {
throw new Error(formatJsonParseFailure(statusCommand, status, err), { cause: err });
}
const dnsName = parsed.Self?.DNSName?.replace(/\.$/, "");
if (!dnsName) {
throw new Error("tailscale DNS name missing; run tailscale up");
}
const target =
typeof params.target === "string" && params.target.trim().length > 0
? params.target.trim()
: params.port
? String(params.port)
: "";
if (!target) {
throw new Error("tailscale target missing; set a port or target URL");
}
const pathArg = normalizeServePath(params.path);
const funnelArgs = [params.mode, "--bg", "--set-path", pathArg, "--yes", target];
const funnelResult = await runCommandWithTimeout([tailscaleBin, ...funnelArgs], {
timeoutMs: 30_000,
signal: params.signal,
});
if (funnelResult.code !== 0) {
throw new Error(formatCommandResult(`tailscale ${params.mode}`, funnelResult));
}
const baseUrl = `https://${dnsName}${pathArg}`;
// Funnel/serve strips pathArg before proxying; keep it only in the public URL.
return params.token ? `${baseUrl}?token=${params.token}` : baseUrl;
}
export async function resolveProjectIdFromGogCredentials(): Promise<string | null> {
const candidates = gogCredentialsPaths();
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) {
continue;
}
try {
const raw = fs.readFileSync(candidate, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const clientId = extractGogClientId(parsed);
const projectNumber = extractProjectNumber(clientId);
if (!projectNumber) {
continue;
}
const res = await runGcloudCommand(
[
"projects",
"list",
"--filter",
`projectNumber=${projectNumber}`,
"--format",
"value(projectId)",
],
30_000,
);
if (res.code !== 0) {
continue;
}
const projectId = res.stdout.trim().split(/\s+/)[0];
if (projectId) {
return projectId;
}
} catch {
// keep scanning
}
}
return null;
}
function gogCredentialsPaths(): string[] {
const paths: string[] = [];
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg) {
paths.push(path.join(xdg, "gogcli", "credentials.json"));
}
paths.push(resolveUserPath("~/.config/gogcli/credentials.json"));
if (process.platform === "darwin") {
paths.push(resolveUserPath("~/Library/Application Support/gogcli/credentials.json"));
}
return paths;
}
function extractGogClientId(parsed: Record<string, unknown>): string | null {
const installed = parsed.installed as Record<string, unknown> | undefined;
const web = parsed.web as Record<string, unknown> | undefined;
const candidate = installed?.client_id || web?.client_id || parsed.client_id || "";
return typeof candidate === "string" ? candidate : null;
}
function extractProjectNumber(clientId: string | null): string | null {
if (!clientId) {
return null;
}
const match = clientId.match(/^(\d+)-/);
return match?.[1] ?? null;
}