feat: add gmail hooks wizard

This commit is contained in:
Peter Steinberger
2025-12-24 19:39:36 +00:00
parent aeb5455555
commit 523d9ec3c2
10 changed files with 1332 additions and 1 deletions

View File

@@ -0,0 +1,239 @@
import fs from "node:fs";
import path from "node:path";
import { hasBinary } from "../agents/skills.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { normalizeServePath } from "./gmail.js";
export async function ensureDependency(bin: string, brewArgs: string[]) {
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 result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
timeoutMs: 600_000,
});
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 runCommandWithTimeout(
[
"gcloud",
"auth",
"list",
"--filter",
"status:ACTIVE",
"--format",
"value(account)",
],
{ timeoutMs: 30_000 },
);
if (res.code === 0 && res.stdout.trim()) return;
const login = await runCommandWithTimeout(["gcloud", "auth", "login"], {
timeoutMs: 600_000,
});
if (login.code !== 0) {
throw new Error(login.stderr || "gcloud auth login failed");
}
}
export async function runGcloud(args: string[]) {
const result = await runCommandWithTimeout(["gcloud", ...args], {
timeoutMs: 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 runCommandWithTimeout(
[
"gcloud",
"pubsub",
"topics",
"describe",
topicName,
"--project",
projectId,
],
{ timeoutMs: 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 runCommandWithTimeout(
[
"gcloud",
"pubsub",
"subscriptions",
"describe",
subscription,
"--project",
projectId,
],
{ timeoutMs: 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;
token?: string;
}): Promise<string> {
if (params.mode === "off") return "";
const status = await runCommandWithTimeout(
["tailscale", "status", "--json"],
{
timeoutMs: 30_000,
},
);
if (status.code !== 0) {
throw new Error(status.stderr || "tailscale status failed");
}
const parsed = JSON.parse(status.stdout) as {
Self?: { DNSName?: string };
};
const dnsName = parsed.Self?.DNSName?.replace(/\.$/, "");
if (!dnsName) {
throw new Error("tailscale DNS name missing; run tailscale up");
}
const target = String(params.port);
const pathArg = normalizeServePath(params.path);
const funnelArgs = [
"tailscale",
params.mode,
"--bg",
"--set-path",
pathArg,
"--yes",
target,
];
const funnelResult = await runCommandWithTimeout(funnelArgs, {
timeoutMs: 30_000,
});
if (funnelResult.code !== 0) {
throw new Error(funnelResult.stderr || "tailscale funnel failed");
}
const baseUrl = `https://${dnsName}${pathArg}`;
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 runCommandWithTimeout(
[
"gcloud",
"projects",
"list",
"--filter",
`projectNumber=${projectNumber}`,
"--format",
"value(projectId)",
],
{ timeoutMs: 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;
}