causly-server 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +120 -0
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +205 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const GMAIL_API = "https://gmail.googleapis.com/gmail/v1";
|
|
2
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
3
|
+
|
|
4
|
+
let cachedAccessToken = null;
|
|
5
|
+
let cachedExpiry = 0;
|
|
6
|
+
|
|
7
|
+
function getCreds() {
|
|
8
|
+
const client_id = process.env.GMAIL_CLIENT_ID;
|
|
9
|
+
const client_secret = process.env.GMAIL_CLIENT_SECRET;
|
|
10
|
+
const refresh_token = process.env.GMAIL_REFRESH_TOKEN;
|
|
11
|
+
if (!client_id || !client_secret || !refresh_token) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"Gmail credentials not set. Run `npm run setup` to configure them, or add to .env: " +
|
|
14
|
+
"GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return { client_id, client_secret, refresh_token };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function getAccessToken() {
|
|
21
|
+
if (cachedAccessToken && Date.now() < cachedExpiry - 30000) {
|
|
22
|
+
return cachedAccessToken;
|
|
23
|
+
}
|
|
24
|
+
const { client_id, client_secret, refresh_token } = getCreds();
|
|
25
|
+
const res = await fetch(TOKEN_URL, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
28
|
+
body: new URLSearchParams({
|
|
29
|
+
client_id,
|
|
30
|
+
client_secret,
|
|
31
|
+
refresh_token,
|
|
32
|
+
grant_type: "refresh_token",
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new Error(`Gmail OAuth refresh error: ${data.error_description || data.error || res.statusText}`);
|
|
38
|
+
}
|
|
39
|
+
cachedAccessToken = data.access_token;
|
|
40
|
+
cachedExpiry = Date.now() + data.expires_in * 1000;
|
|
41
|
+
return cachedAccessToken;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function gmailFetch(path, options = {}) {
|
|
45
|
+
const token = await getAccessToken();
|
|
46
|
+
const res = await fetch(`${GMAIL_API}${path}`, {
|
|
47
|
+
...options,
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
51
|
+
...options.headers,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = text ? JSON.parse(text) : null;
|
|
59
|
+
} catch {
|
|
60
|
+
data = text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const message = data?.error?.message || res.statusText;
|
|
65
|
+
throw new Error(`Gmail API error (${res.status}): ${message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function base64UrlEncode(str) {
|
|
72
|
+
return Buffer.from(str, "utf-8")
|
|
73
|
+
.toString("base64")
|
|
74
|
+
.replace(/\+/g, "-")
|
|
75
|
+
.replace(/\//g, "_")
|
|
76
|
+
.replace(/=+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodeBase64Url(str) {
|
|
80
|
+
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractHeader(headers, name) {
|
|
84
|
+
return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractBody(payload) {
|
|
88
|
+
if (!payload) return "";
|
|
89
|
+
if (payload.body?.data) return decodeBase64Url(payload.body.data);
|
|
90
|
+
if (payload.parts) {
|
|
91
|
+
const textPart = payload.parts.find((p) => p.mimeType === "text/plain") || payload.parts[0];
|
|
92
|
+
if (textPart) return extractBody(textPart);
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildRawMessage({ to, subject, body, inReplyTo, references, cc, bcc }) {
|
|
98
|
+
const lines = [
|
|
99
|
+
`To: ${to}`,
|
|
100
|
+
cc ? `Cc: ${cc}` : null,
|
|
101
|
+
bcc ? `Bcc: ${bcc}` : null,
|
|
102
|
+
`Subject: ${subject}`,
|
|
103
|
+
inReplyTo ? `In-Reply-To: ${inReplyTo}` : null,
|
|
104
|
+
references ? `References: ${references}` : null,
|
|
105
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
106
|
+
"",
|
|
107
|
+
body,
|
|
108
|
+
].filter(Boolean);
|
|
109
|
+
return base64UrlEncode(lines.join("\r\n"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get the authenticated Gmail account's profile — useful as a connectivity/auth check. */
|
|
113
|
+
export async function gmailGetProfile() {
|
|
114
|
+
const data = await gmailFetch("/users/me/profile");
|
|
115
|
+
return { emailAddress: data.emailAddress, messagesTotal: data.messagesTotal, threadsTotal: data.threadsTotal };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Search messages using Gmail search syntax (e.g. "from:x@y.com is:unread"). */
|
|
119
|
+
export async function gmailSearch({ query, max_results = 10 }) {
|
|
120
|
+
const data = await gmailFetch(`/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${max_results}`);
|
|
121
|
+
return { query, messages: (data.messages || []).map((m) => ({ id: m.id, threadId: m.threadId })) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** List recent messages (optionally scoped to a label). */
|
|
125
|
+
export async function gmailListMessages({ label_ids, max_results = 10 }) {
|
|
126
|
+
const params = new URLSearchParams({ maxResults: String(max_results) });
|
|
127
|
+
if (label_ids) params.set("labelIds", label_ids);
|
|
128
|
+
const data = await gmailFetch(`/users/me/messages?${params.toString()}`);
|
|
129
|
+
return { messages: (data.messages || []).map((m) => ({ id: m.id, threadId: m.threadId })) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get a single message's content (decoded), by message ID. */
|
|
133
|
+
export async function gmailGetMessage({ message_id }) {
|
|
134
|
+
const data = await gmailFetch(`/users/me/messages/${message_id}?format=full`);
|
|
135
|
+
const headers = data.payload?.headers;
|
|
136
|
+
return {
|
|
137
|
+
id: data.id,
|
|
138
|
+
threadId: data.threadId,
|
|
139
|
+
from: extractHeader(headers, "From"),
|
|
140
|
+
to: extractHeader(headers, "To"),
|
|
141
|
+
subject: extractHeader(headers, "Subject"),
|
|
142
|
+
date: extractHeader(headers, "Date"),
|
|
143
|
+
messageIdHeader: extractHeader(headers, "Message-ID"),
|
|
144
|
+
snippet: data.snippet,
|
|
145
|
+
body: extractBody(data.payload),
|
|
146
|
+
labelIds: data.labelIds,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Get a full thread (all messages in it), by thread ID. */
|
|
151
|
+
export async function gmailGetThread({ thread_id }) {
|
|
152
|
+
const data = await gmailFetch(`/users/me/threads/${thread_id}?format=full`);
|
|
153
|
+
return {
|
|
154
|
+
id: data.id,
|
|
155
|
+
messages: (data.messages || []).map((m) => {
|
|
156
|
+
const headers = m.payload?.headers;
|
|
157
|
+
return {
|
|
158
|
+
id: m.id,
|
|
159
|
+
from: extractHeader(headers, "From"),
|
|
160
|
+
to: extractHeader(headers, "To"),
|
|
161
|
+
subject: extractHeader(headers, "Subject"),
|
|
162
|
+
date: extractHeader(headers, "Date"),
|
|
163
|
+
snippet: m.snippet,
|
|
164
|
+
body: extractBody(m.payload),
|
|
165
|
+
};
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Send a new email. HIGH risk — requires confirm: true. */
|
|
171
|
+
export async function gmailSend({ to, subject, body, cc, bcc, confirm }) {
|
|
172
|
+
const raw = buildRawMessage({ to, subject, body, cc, bcc });
|
|
173
|
+
const data = await gmailFetch("/users/me/messages/send", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
body: JSON.stringify({ raw }),
|
|
176
|
+
});
|
|
177
|
+
return { id: data.id, threadId: data.threadId, sent: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Reply to an existing message (same thread). HIGH risk — requires confirm: true. */
|
|
181
|
+
export async function gmailReply({ message_id, body, confirm }) {
|
|
182
|
+
const original = await gmailFetch(`/users/me/messages/${message_id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Message-ID&metadataHeaders=References`);
|
|
183
|
+
const headers = original.payload?.headers;
|
|
184
|
+
const to = extractHeader(headers, "From");
|
|
185
|
+
let subject = extractHeader(headers, "Subject") || "";
|
|
186
|
+
if (!/^re:/i.test(subject)) subject = `Re: ${subject}`;
|
|
187
|
+
const msgIdHeader = extractHeader(headers, "Message-ID");
|
|
188
|
+
const refsHeader = extractHeader(headers, "References");
|
|
189
|
+
const references = [refsHeader, msgIdHeader].filter(Boolean).join(" ");
|
|
190
|
+
|
|
191
|
+
const raw = buildRawMessage({ to, subject, body, inReplyTo: msgIdHeader, references });
|
|
192
|
+
const data = await gmailFetch("/users/me/messages/send", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
body: JSON.stringify({ raw, threadId: original.threadId }),
|
|
195
|
+
});
|
|
196
|
+
return { id: data.id, threadId: data.threadId, sent: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Forward an existing message to a new recipient. HIGH risk — requires confirm: true. */
|
|
200
|
+
export async function gmailForward({ message_id, to, note = "", confirm }) {
|
|
201
|
+
const original = await gmailGetMessage({ message_id });
|
|
202
|
+
const subject = /^fwd:/i.test(original.subject || "") ? original.subject : `Fwd: ${original.subject || ""}`;
|
|
203
|
+
const body = [
|
|
204
|
+
note,
|
|
205
|
+
"",
|
|
206
|
+
"---------- Forwarded message ---------",
|
|
207
|
+
`From: ${original.from}`,
|
|
208
|
+
`Date: ${original.date}`,
|
|
209
|
+
`Subject: ${original.subject}`,
|
|
210
|
+
`To: ${original.to}`,
|
|
211
|
+
"",
|
|
212
|
+
original.body,
|
|
213
|
+
].join("\n");
|
|
214
|
+
|
|
215
|
+
const raw = buildRawMessage({ to, subject, body });
|
|
216
|
+
const data = await gmailFetch("/users/me/messages/send", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: JSON.stringify({ raw }),
|
|
219
|
+
});
|
|
220
|
+
return { id: data.id, threadId: data.threadId, sent: true };
|
|
221
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Minimal Google OAuth helper: turns a refresh token into a short-lived access token.
|
|
2
|
+
// Used by Google-backed tools that are not Gmail (Search Console now, Calendar later).
|
|
3
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
4
|
+
|
|
5
|
+
const cache = new Map(); // "clientId:refreshToken" -> { token, expiresAt }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read credentials from the environment. Each client id/secret can come from several
|
|
9
|
+
* variable names (first one set wins), so one OAuth client can serve several tools.
|
|
10
|
+
*/
|
|
11
|
+
export function googleCredentials({ refreshTokenEnv, clientIdEnvs, clientSecretEnvs }, env = process.env) {
|
|
12
|
+
const pick = (names) => names.map((name) => env[name]).find(Boolean);
|
|
13
|
+
const client_id = pick(clientIdEnvs);
|
|
14
|
+
const client_secret = pick(clientSecretEnvs);
|
|
15
|
+
const refresh_token = env[refreshTokenEnv];
|
|
16
|
+
const missing = [];
|
|
17
|
+
if (!client_id) missing.push(clientIdEnvs.join(" or "));
|
|
18
|
+
if (!client_secret) missing.push(clientSecretEnvs.join(" or "));
|
|
19
|
+
if (!refresh_token) missing.push(refreshTokenEnv);
|
|
20
|
+
if (missing.length) throw new Error(`Google access is not set up. Missing: ${missing.join(", ")}.`);
|
|
21
|
+
return { client_id, client_secret, refresh_token };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getAccessToken(creds, { fetch: fetchFn = globalThis.fetch, now = Date.now } = {}) {
|
|
25
|
+
const key = `${creds.client_id}:${creds.refresh_token}`;
|
|
26
|
+
const hit = cache.get(key);
|
|
27
|
+
if (hit && hit.expiresAt > now() + 60_000) return hit.token;
|
|
28
|
+
|
|
29
|
+
const res = await fetchFn(TOKEN_URL, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
32
|
+
body: new URLSearchParams({
|
|
33
|
+
client_id: creds.client_id,
|
|
34
|
+
client_secret: creds.client_secret,
|
|
35
|
+
refresh_token: creds.refresh_token,
|
|
36
|
+
grant_type: "refresh_token",
|
|
37
|
+
}),
|
|
38
|
+
signal: AbortSignal.timeout(15_000),
|
|
39
|
+
});
|
|
40
|
+
const data = await res.json().catch(() => ({}));
|
|
41
|
+
if (!res.ok || !data.access_token) {
|
|
42
|
+
const why = [data.error, data.error_description].filter(Boolean).join(": ");
|
|
43
|
+
throw new Error(`Google sign-in failed${why ? ` (${why})` : ""}. Check the client id, client secret and refresh token.`);
|
|
44
|
+
}
|
|
45
|
+
cache.set(key, { token: data.access_token, expiresAt: now() + (Number(data.expires_in) || 3600) * 1000 });
|
|
46
|
+
return data.access_token;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearTokenCache() {
|
|
50
|
+
cache.clear();
|
|
51
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `causly-server login google`: sign in with the browser instead of copying tokens by hand.
|
|
2
|
+
//
|
|
3
|
+
// A tiny web server on 127.0.0.1 (random port) receives Google's redirect, the code is exchanged for a
|
|
4
|
+
// refresh token, and the token is saved to the settings file and checked live. One sign-in can cover
|
|
5
|
+
// Gmail, Calendar and Search Console, so the user clicks Allow once.
|
|
6
|
+
import http from "http";
|
|
7
|
+
import { execFile } from "child_process";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import { randomBytes } from "crypto";
|
|
10
|
+
import { setEnvValue } from "./envFile.js";
|
|
11
|
+
import { envWritePath } from "./paths.js";
|
|
12
|
+
import { findService, checkService } from "./services.js";
|
|
13
|
+
|
|
14
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
15
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
16
|
+
const SCOPE = (s) => `https://www.googleapis.com/auth/${s}`;
|
|
17
|
+
|
|
18
|
+
export const GOOGLE_TARGETS = {
|
|
19
|
+
gmail: { label: "Gmail", tokenEnv: "GMAIL_REFRESH_TOKEN", scopes: ["gmail.readonly", "gmail.send", "gmail.modify"].map(SCOPE), overrides: [] },
|
|
20
|
+
calendar: { label: "Google Calendar", tokenEnv: "CALENDAR_REFRESH_TOKEN", scopes: ["calendar.events", "calendar.readonly"].map(SCOPE), overrides: ["CALENDAR_CLIENT_ID", "CALENDAR_CLIENT_SECRET"] },
|
|
21
|
+
gsc: { label: "Google Search Console", tokenEnv: "GSC_REFRESH_TOKEN", scopes: [SCOPE("webmasters.readonly")], overrides: ["GSC_CLIENT_ID", "GSC_CLIENT_SECRET"] },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_GOOGLE_TARGETS = ["gmail", "calendar"];
|
|
25
|
+
|
|
26
|
+
const CLIENT_SETUP_STEPS = [
|
|
27
|
+
"Open console.cloud.google.com, create a project (or pick one) and enable the Gmail API, Google Calendar API and (optional) Search Console API.",
|
|
28
|
+
"OAuth consent screen: choose External, add your own Google account as a test user. Then press Publish app, otherwise Google expires the token after 7 days.",
|
|
29
|
+
"Credentials, Create credentials, OAuth client ID, type Desktop app. Copy the Client ID and Client secret.",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function parseTargets(value) {
|
|
33
|
+
const names = String(value ?? "")
|
|
34
|
+
.split(/[,\s]+/)
|
|
35
|
+
.map((s) => s.trim().toLowerCase())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
if (!names.length) return [...DEFAULT_GOOGLE_TARGETS];
|
|
38
|
+
const expanded = names.flatMap((n) => (n === "all" ? Object.keys(GOOGLE_TARGETS) : n === "search-console" ? ["gsc"] : [n]));
|
|
39
|
+
const unknown = expanded.filter((n) => !GOOGLE_TARGETS[n]);
|
|
40
|
+
if (unknown.length) throw new Error(`Unknown Google service: ${unknown.join(", ")}. Choose from: ${Object.keys(GOOGLE_TARGETS).join(", ")}, all.`);
|
|
41
|
+
return [...new Set(expanded)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildAuthUrl({ clientId, redirectUri, scopes, state }) {
|
|
45
|
+
const u = new URL(AUTH_URL);
|
|
46
|
+
u.search = new URLSearchParams({
|
|
47
|
+
client_id: clientId,
|
|
48
|
+
redirect_uri: redirectUri,
|
|
49
|
+
response_type: "code",
|
|
50
|
+
scope: scopes.join(" "),
|
|
51
|
+
access_type: "offline",
|
|
52
|
+
prompt: "consent", // makes Google return a refresh token every time
|
|
53
|
+
state,
|
|
54
|
+
}).toString();
|
|
55
|
+
return u.toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PAGE = (title, body) =>
|
|
59
|
+
`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem"><h2>${title}</h2><p>${body}</p></body>`;
|
|
60
|
+
|
|
61
|
+
/** Local server that waits for Google's redirect. Resolves with the code; rejects on error, wrong state or timeout. */
|
|
62
|
+
export function startCallbackServer({ state, timeoutMs = 300_000 } = {}) {
|
|
63
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
64
|
+
let settle;
|
|
65
|
+
const result = new Promise((resolve, reject) => (settle = { resolve, reject }));
|
|
66
|
+
result.catch(() => {}); // the caller awaits it later; avoid an unhandled rejection meanwhile
|
|
67
|
+
const server = http.createServer((req, res) => {
|
|
68
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
69
|
+
if (url.pathname !== "/") {
|
|
70
|
+
res.writeHead(404).end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const reply = (status, title, body) => {
|
|
74
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(PAGE(title, body));
|
|
75
|
+
};
|
|
76
|
+
if (url.searchParams.get("state") !== state) {
|
|
77
|
+
reply(400, "Wrong request", "This sign-in does not belong to the running causly-server command. Start it again from the terminal.");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const error = url.searchParams.get("error");
|
|
81
|
+
const code = url.searchParams.get("code");
|
|
82
|
+
if (error) {
|
|
83
|
+
reply(200, "Sign-in cancelled", "You can close this tab and return to the terminal.");
|
|
84
|
+
settle.reject(new Error(error === "access_denied" ? "You did not allow access in the browser." : `Google returned an error: ${error}`));
|
|
85
|
+
} else if (code) {
|
|
86
|
+
reply(200, "Signed in", "causly-server is connected. You can close this tab and return to the terminal.");
|
|
87
|
+
settle.resolve(code);
|
|
88
|
+
} else {
|
|
89
|
+
reply(400, "Nothing to do", "No code in the request.");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
const timer = setTimeout(() => settle.reject(new Error("Timed out waiting for the browser sign-in (5 minutes). Run the command again.")), timeoutMs);
|
|
93
|
+
server.on("error", rejectStart);
|
|
94
|
+
server.listen(0, "127.0.0.1", () => {
|
|
95
|
+
resolveStart({
|
|
96
|
+
redirectUri: `http://127.0.0.1:${server.address().port}`,
|
|
97
|
+
result,
|
|
98
|
+
close: () => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
server.close();
|
|
101
|
+
server.closeAllConnections?.();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function openBrowser(url, { platform = os.platform(), exec = execFile } = {}) {
|
|
109
|
+
const [cmd, args] =
|
|
110
|
+
platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
111
|
+
try {
|
|
112
|
+
exec(cmd, args, { windowsHide: true }, () => {});
|
|
113
|
+
} catch {
|
|
114
|
+
// the URL is printed as well, so the user can open it by hand
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function exchangeCode({ code, clientId, clientSecret, redirectUri, fetch: fetchFn = globalThis.fetch }) {
|
|
119
|
+
const res = await fetchFn(TOKEN_URL, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
122
|
+
body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: "authorization_code" }),
|
|
123
|
+
signal: AbortSignal.timeout(15_000),
|
|
124
|
+
});
|
|
125
|
+
const body = await res.json().catch(() => ({}));
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
if (body.error === "invalid_client") throw new Error("Google rejected the client id or secret. Check them with: causly-server keys list");
|
|
128
|
+
throw new Error(`Google could not finish the sign-in: ${body.error_description || body.error || `HTTP ${res.status}`}`);
|
|
129
|
+
}
|
|
130
|
+
if (!body.refresh_token) {
|
|
131
|
+
throw new Error("Google did not return a refresh token. Remove causly-server at https://myaccount.google.com/permissions and run the command again.");
|
|
132
|
+
}
|
|
133
|
+
return { refreshToken: body.refresh_token, scope: String(body.scope || "") };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pickFirst = (env, names) => names.map((n) => env[n]).find(Boolean);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sign in to Google and save the refresh token for each chosen service.
|
|
140
|
+
* term: from terminal.js (only needed when the OAuth client id/secret are not saved yet).
|
|
141
|
+
*/
|
|
142
|
+
export async function runGoogleLogin({
|
|
143
|
+
targets = DEFAULT_GOOGLE_TARGETS,
|
|
144
|
+
term,
|
|
145
|
+
env = process.env,
|
|
146
|
+
file = envWritePath(),
|
|
147
|
+
fetch: fetchFn = globalThis.fetch,
|
|
148
|
+
open = openBrowser,
|
|
149
|
+
timeoutMs,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const say = (t = "") => term.write(`${t}\n`);
|
|
152
|
+
const chosen = parseTargets(Array.isArray(targets) ? targets.join(",") : targets);
|
|
153
|
+
const result = { saved: [], not_saved: [], checks: [] };
|
|
154
|
+
|
|
155
|
+
// 1. the OAuth client (the app registration), asked for once
|
|
156
|
+
let clientId = pickFirst(env, ["GMAIL_CLIENT_ID", "CALENDAR_CLIENT_ID", "GSC_CLIENT_ID"]);
|
|
157
|
+
let clientSecret = pickFirst(env, ["GMAIL_CLIENT_SECRET", "CALENDAR_CLIENT_SECRET", "GSC_CLIENT_SECRET"]);
|
|
158
|
+
const newClient = {};
|
|
159
|
+
if (!clientId || !clientSecret) {
|
|
160
|
+
say("\nFirst, a Google OAuth client is needed (one time, takes a few minutes):");
|
|
161
|
+
CLIENT_SETUP_STEPS.forEach((s, i) => say(` ${i + 1}. ${s}`));
|
|
162
|
+
say(" Link: https://console.cloud.google.com/apis/credentials\n");
|
|
163
|
+
if (!clientId) clientId = (await term.line("Client ID: ")).trim();
|
|
164
|
+
if (!clientSecret) clientSecret = (await term.secret("Client secret: ")).trim();
|
|
165
|
+
if (!clientId || !clientSecret) throw new Error("A client id and secret are needed to sign in. Nothing saved.");
|
|
166
|
+
newClient.GMAIL_CLIENT_ID = clientId;
|
|
167
|
+
newClient.GMAIL_CLIENT_SECRET = clientSecret;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// a service with its own client would get a token that does not match it
|
|
171
|
+
for (const t of chosen) {
|
|
172
|
+
const own = GOOGLE_TARGETS[t].overrides;
|
|
173
|
+
const [oid, osec] = own.map((n) => env[n]);
|
|
174
|
+
if ((oid && oid !== clientId) || (osec && osec !== clientSecret)) {
|
|
175
|
+
throw new Error(`${own[0]} is set to a different OAuth client than the one used for sign-in. Remove ${own.join(" and ")} (causly-server keys unset NAME) to use one client, or sign in for that service separately.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. browser sign-in
|
|
180
|
+
const state = randomBytes(16).toString("hex");
|
|
181
|
+
const server = await startCallbackServer({ state, timeoutMs });
|
|
182
|
+
let code;
|
|
183
|
+
try {
|
|
184
|
+
const scopes = [...new Set(chosen.flatMap((t) => GOOGLE_TARGETS[t].scopes))];
|
|
185
|
+
const url = buildAuthUrl({ clientId, redirectUri: server.redirectUri, scopes, state });
|
|
186
|
+
say(`\nSigning in for: ${chosen.map((t) => GOOGLE_TARGETS[t].label).join(", ")}`);
|
|
187
|
+
say("Your browser should open. If it does not, open this link:\n");
|
|
188
|
+
say(url);
|
|
189
|
+
say("\nGoogle may say the app is not verified: choose Advanced, then continue (it is your own app). Tick every permission box, then press Allow.");
|
|
190
|
+
say('If Google shows "redirect_uri_mismatch", the OAuth client is a Web application. Create a new client of type "Desktop app" and save it with: causly-server keys set GMAIL_CLIENT_ID (and GMAIL_CLIENT_SECRET).');
|
|
191
|
+
say("Waiting for you in the browser...");
|
|
192
|
+
open(url);
|
|
193
|
+
code = await server.result;
|
|
194
|
+
} finally {
|
|
195
|
+
server.close();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 3. code -> refresh token
|
|
199
|
+
const { refreshToken, scope } = await exchangeCode({ code, clientId, clientSecret, redirectUri: server.redirectUri, fetch: fetchFn });
|
|
200
|
+
|
|
201
|
+
// 4. save, for the services whose permissions were all granted
|
|
202
|
+
for (const [name, value] of Object.entries(newClient)) {
|
|
203
|
+
setEnvValue(file, name, value);
|
|
204
|
+
env[name] = value;
|
|
205
|
+
}
|
|
206
|
+
for (const t of chosen) {
|
|
207
|
+
const target = GOOGLE_TARGETS[t];
|
|
208
|
+
const missing = target.scopes.filter((s) => !scope.includes(s));
|
|
209
|
+
if (missing.length) {
|
|
210
|
+
result.not_saved.push({ target: t, missing: missing.map((s) => s.split("/").pop()) });
|
|
211
|
+
say(`\n${target.label}: not saved, you did not allow: ${missing.map((s) => s.split("/").pop()).join(", ")}. Run the command again and tick every box.`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
setEnvValue(file, target.tokenEnv, refreshToken);
|
|
215
|
+
env[target.tokenEnv] = refreshToken;
|
|
216
|
+
result.saved.push(t);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 5. live check
|
|
220
|
+
const services = { gmail: "gmail", calendar: "calendar", gsc: "gsc" };
|
|
221
|
+
for (const t of result.saved) {
|
|
222
|
+
const svc = findService(services[t]);
|
|
223
|
+
if (!svc) continue;
|
|
224
|
+
const r = await checkService(svc, env, { fetch: fetchFn });
|
|
225
|
+
result.checks.push(r);
|
|
226
|
+
say(`${r.ok ? "ok " : "FAIL"} ${GOOGLE_TARGETS[t].label}: ${r.detail}`);
|
|
227
|
+
}
|
|
228
|
+
if (result.saved.length) say(`\nSaved to ${file}. Running tools pick it up on their next call.`);
|
|
229
|
+
return result;
|
|
230
|
+
}
|