import fs from "node:fs" ;
import path from "node:path" ;
import { hasBinary } from "../agents/skills.js" ;
import { formatErrorMessage } from "../infra/errors.js" ;
import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js" ;
import { resolveUserPath } from "../utils.js" ;
import { normalizeServePath } from "./gmail.js" ;
let cachedPythonPath: string | null | undefined;
const MAX_OUTPUT_CHARS = 800 ;
export function resetGmailSetupUtilsCachesForTest(): void {
cachedPythonPath = undefined;
}
function trimOutput(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "" ;
}
if (trimmed.length <= MAX_OUTPUT_CHARS) {
return trimmed;
}
return `${trimmed.slice(0 , MAX_OUTPUT_CHARS)}…`;
}
function formatCommandResultInternal(
command: string,
result: SpawnResult,
statusLabel: "failed" | "exited" ,
): string {
const code = result.code ?? "null" ;
const signal = result.signal ? `, signal=${result.signal}` : "" ;
const killed = result.killed ? ", killed=true" : "" ;
const stderr = trimOutput(result.stderr);
const stdout = trimOutput(result.stdout);
const lines = [`${command} ${statusLabel} (code=${code}${signal}${killed})`];
if (stderr) {
lines.push(`stderr: ${stderr}`);
}
if (stdout) {
lines.push(`stdout: ${stdout}`);
}
return lines.join("\n" );
}
function formatCommandFailure(command: string, result: SpawnResult): string {
return formatCommandResultInternal(command, result, "failed" );
}
function formatCommandResult(command: string, result: SpawnResult): string {
return formatCommandResultInternal(command, result, "exited" );
}
function formatJsonParseFailure(command: string, result: SpawnResult, err: unknown): string {
const reason = formatErrorMessage(err);
return `${command} returned invalid JSON: ${reason}\n${formatCommandResult(command, result)}`;
}
function formatCommand(command: string, args: string[]): string {
return [command, ...args].join(" " );
}
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[]) {
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(`brew install failed for ${bin}: ${result.stderr || result.stdout}`);
}
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(login.stderr || "gcloud auth login failed" );
}
}
export async function runGcloud(args: string[]) {
const result = await runGcloudCommand(args, 120 _000 );
if (result.code !== 0 ) {
throw new Error(result.stderr || result.stdout || "gcloud command failed" );
}
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;
target?: string;
token?: string;
}): Promise<string> {
if (params.mode === "off" ) {
return "" ;
}
const statusArgs = ["status" , "--json" ];
const statusCommand = formatCommand("tailscale" , statusArgs);
const status = await runCommandWithTimeout(["tailscale" , ...statusArgs], {
timeoutMs: 30 _000 ,
});
if (status.code !== 0 ) {
throw new Error(formatCommandFailure(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 funnelCommand = formatCommand("tailscale" , funnelArgs);
const funnelResult = await runCommandWithTimeout(["tailscale" , ...funnelArgs], {
timeoutMs: 30 _000 ,
});
if (funnelResult.code !== 0 ) {
throw new Error(formatCommandFailure(funnelCommand, 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 ;
}
Messung V0.5 in Prozent C=99 H=96 G=97
¤ Dauer der Verarbeitung: 0.29 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland