causly-server 0.0.0-stage → 1.5.1
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 +126 -0
- package/LICENSE +21 -0
- package/README.md +377 -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,295 @@
|
|
|
1
|
+
// One list of every service the server can talk to: which settings it needs, where to get the key,
|
|
2
|
+
// and a cheap live check. `setup`, `keys`, `doctor --test` and the "key missing" hints all read this.
|
|
3
|
+
//
|
|
4
|
+
// A check is async (env, { fetch }) => { detail } and throws an Error with a plain message when
|
|
5
|
+
// something is wrong. Checks only read from the service; they never change anything.
|
|
6
|
+
|
|
7
|
+
const TIMEOUT_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
const scrub = (msg, env, names) => {
|
|
10
|
+
let out = String(msg);
|
|
11
|
+
for (const n of names) if (env[n] && String(env[n]).length >= 6) out = out.split(env[n]).join("[REDACTED]"); // very short values are not secrets and would mangle the text
|
|
12
|
+
return out;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function call(fetchFn, url, init = {}) {
|
|
16
|
+
let res;
|
|
17
|
+
try {
|
|
18
|
+
res = await fetchFn(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
throw new Error(`Could not reach ${new URL(url).host}: ${err.message}`);
|
|
21
|
+
}
|
|
22
|
+
const body = await res.json().catch(() => ({}));
|
|
23
|
+
return { res, body };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rejected = (status, what = "the key") => {
|
|
27
|
+
if (status === 401) return new Error(`The service rejected ${what} (401). Check that it was copied completely and has not been revoked.`);
|
|
28
|
+
if (status === 403) return new Error(`${what[0].toUpperCase()}${what.slice(1)} is valid but lacks permission (403). Check its scopes.`);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const bearer = (token) => ({ authorization: `Bearer ${token}` });
|
|
33
|
+
|
|
34
|
+
/** Google refresh-token check, shared by Gmail and Calendar. */
|
|
35
|
+
async function googleCheck(env, { fetch: f }, { tokenName, idNames, secretNames, scopeAll, scopeAny }) {
|
|
36
|
+
const pick = (names) => names.map((n) => env[n]).find(Boolean);
|
|
37
|
+
const clientId = pick(idNames);
|
|
38
|
+
const clientSecret = pick(secretNames);
|
|
39
|
+
if (!clientId || !clientSecret) throw new Error(`Needs ${idNames[0]} and ${secretNames[0]} too.`);
|
|
40
|
+
const form = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: env[tokenName], grant_type: "refresh_token" });
|
|
41
|
+
const { res, body } = await call(f, "https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form });
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
if (body.error === "invalid_grant") throw new Error("Google says the refresh token is expired or revoked. If your Google Cloud app is in Testing mode, tokens expire after 7 days: publish the app, then sign in again with: causly-server login google");
|
|
44
|
+
if (body.error === "invalid_client") throw new Error("Google rejected the client id or secret.");
|
|
45
|
+
throw new Error(`Google error: ${body.error_description || body.error || `HTTP ${res.status}`}`);
|
|
46
|
+
}
|
|
47
|
+
const scope = String(body.scope || "");
|
|
48
|
+
if (scope) {
|
|
49
|
+
const missing = (scopeAll || []).filter((s) => !scope.includes(s));
|
|
50
|
+
if (missing.length) throw new Error(`The token works but is missing: ${missing.join(", ")}. Authorize again with all the scopes listed in the steps.`);
|
|
51
|
+
if (scopeAny && !scopeAny.some((s) => scope.includes(s))) throw new Error(`The token works but has none of the needed scopes (${scopeAny.join(", ")}).`);
|
|
52
|
+
}
|
|
53
|
+
return { detail: "token refreshes and has the right scopes" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const SERVICES = [
|
|
57
|
+
{
|
|
58
|
+
id: "github",
|
|
59
|
+
name: "GitHub",
|
|
60
|
+
env: [{ name: "GITHUB_TOKEN", secret: true }],
|
|
61
|
+
url: "https://github.com/settings/tokens?type=beta",
|
|
62
|
+
steps: ["Create a fine-grained token.", "Give it Contents, Issues and Pull requests (read and write). Add Administration only if you want repo create/delete."],
|
|
63
|
+
check: async (env, { fetch: f }) => {
|
|
64
|
+
const { res, body } = await call(f, "https://api.github.com/user", { headers: { ...bearer(env.GITHUB_TOKEN), "user-agent": "causly-server", accept: "application/vnd.github+json" } });
|
|
65
|
+
if (!res.ok) throw rejected(res.status) || new Error(`GitHub error: ${body.message || res.status}`);
|
|
66
|
+
return { detail: `signed in as ${body.login}` };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "slack",
|
|
71
|
+
name: "Slack",
|
|
72
|
+
env: [{ name: "SLACK_BOT_TOKEN", secret: true }],
|
|
73
|
+
url: "https://api.slack.com/apps",
|
|
74
|
+
steps: ["Create an app, open OAuth & Permissions.", "Add bot scopes: channels:read, channels:history, groups:read, groups:history, chat:write, users:read.", "Install to the workspace and copy the Bot token (xoxb-...)."],
|
|
75
|
+
check: async (env, { fetch: f }) => {
|
|
76
|
+
const { body } = await call(f, "https://slack.com/api/auth.test", { method: "POST", headers: bearer(env.SLACK_BOT_TOKEN) });
|
|
77
|
+
if (!body.ok) throw new Error(`Slack says: ${body.error || "unknown error"}${body.error === "invalid_auth" ? " (check the token)" : ""}`);
|
|
78
|
+
return { detail: `workspace ${body.team}, bot ${body.user}` };
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "gmail",
|
|
83
|
+
name: "Gmail",
|
|
84
|
+
env: [{ name: "GMAIL_CLIENT_ID", secret: false }, { name: "GMAIL_CLIENT_SECRET", secret: true }, { name: "GMAIL_REFRESH_TOKEN", secret: true }],
|
|
85
|
+
login: "gmail",
|
|
86
|
+
url: "https://developers.google.com/oauthplayground",
|
|
87
|
+
steps: [
|
|
88
|
+
"In console.cloud.google.com enable the Gmail API, set up the OAuth consent screen and create an OAuth client (Desktop app).",
|
|
89
|
+
"In OAuth Playground (gear icon: use your own credentials) authorize gmail.readonly, gmail.send, gmail.modify.",
|
|
90
|
+
"Exchange the code and copy the Refresh token.",
|
|
91
|
+
],
|
|
92
|
+
check: (env, deps) => googleCheck(env, deps, { tokenName: "GMAIL_REFRESH_TOKEN", idNames: ["GMAIL_CLIENT_ID"], secretNames: ["GMAIL_CLIENT_SECRET"], scopeAny: ["gmail", "mail.google.com"] }),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "calendar",
|
|
96
|
+
name: "Google Calendar",
|
|
97
|
+
env: [
|
|
98
|
+
{ name: "CALENDAR_REFRESH_TOKEN", secret: true },
|
|
99
|
+
{ name: "CALENDAR_CLIENT_ID", secret: false, optional: true, note: "Only if different from the Gmail client" },
|
|
100
|
+
{ name: "CALENDAR_CLIENT_SECRET", secret: true, optional: true, note: "Only if different from the Gmail client" },
|
|
101
|
+
{ name: "CAUSLY_TIMEZONE", secret: false, optional: true, note: "e.g. Asia/Kolkata (default: this computer's zone)" },
|
|
102
|
+
],
|
|
103
|
+
login: "calendar",
|
|
104
|
+
url: "https://developers.google.com/oauthplayground",
|
|
105
|
+
steps: [
|
|
106
|
+
"Enable the Google Calendar API in the same Google Cloud project as Gmail.",
|
|
107
|
+
"In OAuth Playground (your own credentials) authorize BOTH https://www.googleapis.com/auth/calendar.events and https://www.googleapis.com/auth/calendar.readonly.",
|
|
108
|
+
"Exchange the code and copy the Refresh token.",
|
|
109
|
+
"The Gmail client id and secret are reused unless you set CALENDAR_CLIENT_ID and CALENDAR_CLIENT_SECRET.",
|
|
110
|
+
],
|
|
111
|
+
check: (env, deps) =>
|
|
112
|
+
googleCheck(env, deps, {
|
|
113
|
+
tokenName: "CALENDAR_REFRESH_TOKEN",
|
|
114
|
+
idNames: ["CALENDAR_CLIENT_ID", "GMAIL_CLIENT_ID"],
|
|
115
|
+
secretNames: ["CALENDAR_CLIENT_SECRET", "GMAIL_CLIENT_SECRET"],
|
|
116
|
+
scopeAll: ["calendar.events", "calendar.readonly"],
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "gsc",
|
|
121
|
+
name: "Google Search Console",
|
|
122
|
+
env: [
|
|
123
|
+
{ name: "GSC_REFRESH_TOKEN", secret: true },
|
|
124
|
+
{ name: "GSC_CLIENT_ID", secret: false, optional: true, note: "Only if different from the Gmail client" },
|
|
125
|
+
{ name: "GSC_CLIENT_SECRET", secret: true, optional: true, note: "Only if different from the Gmail client" },
|
|
126
|
+
],
|
|
127
|
+
login: "gsc",
|
|
128
|
+
url: "https://developers.google.com/oauthplayground",
|
|
129
|
+
steps: [
|
|
130
|
+
"Enable the Search Console API in the same Google Cloud project as Gmail.",
|
|
131
|
+
"In OAuth Playground (your own credentials) authorize https://www.googleapis.com/auth/webmasters.readonly.",
|
|
132
|
+
"Exchange the code and copy the Refresh token.",
|
|
133
|
+
],
|
|
134
|
+
check: (env, deps) =>
|
|
135
|
+
googleCheck(env, deps, {
|
|
136
|
+
tokenName: "GSC_REFRESH_TOKEN",
|
|
137
|
+
idNames: ["GSC_CLIENT_ID", "GMAIL_CLIENT_ID"],
|
|
138
|
+
secretNames: ["GSC_CLIENT_SECRET", "GMAIL_CLIENT_SECRET"],
|
|
139
|
+
scopeAny: ["webmasters"],
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "telegram",
|
|
144
|
+
name: "Telegram",
|
|
145
|
+
env: [{ name: "TELEGRAM_BOT_TOKEN", secret: true }, { name: "TELEGRAM_CHAT_ID", secret: false, discover: true, note: "Found automatically after you message the bot" }],
|
|
146
|
+
url: "https://t.me/BotFather",
|
|
147
|
+
steps: ["In Telegram open @BotFather, send /newbot, pick a name and username.", "Copy the token it gives you.", "Then open your new bot and press Start so it can see your chat."],
|
|
148
|
+
check: async (env, { fetch: f }) => {
|
|
149
|
+
const { res, body } = await call(f, `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/getMe`);
|
|
150
|
+
if (!res.ok || !body.ok) throw new Error(res.status === 401 || res.status === 404 ? "Telegram rejected the bot token (check it was copied completely)." : `Telegram error: ${body.description || res.status}`);
|
|
151
|
+
return { detail: `bot @${body.result.username}` };
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "linear",
|
|
156
|
+
name: "Linear",
|
|
157
|
+
env: [{ name: "LINEAR_API_KEY", secret: true }],
|
|
158
|
+
url: "https://linear.app/settings/account/security",
|
|
159
|
+
steps: ["Settings, Account, Security & access, Personal API keys.", "Create a key and copy it (starts with lin_api_)."],
|
|
160
|
+
check: async (env, { fetch: f }) => {
|
|
161
|
+
const { res, body } = await call(f, "https://api.linear.app/graphql", { method: "POST", headers: { authorization: env.LINEAR_API_KEY, "content-type": "application/json" }, body: JSON.stringify({ query: "{ viewer { name } organization { name } }" }) });
|
|
162
|
+
if (!res.ok || body.errors) throw rejected(res.status) || new Error(`Linear error: ${body.errors?.[0]?.message || res.status}`);
|
|
163
|
+
return { detail: `${body.data.viewer.name} in ${body.data.organization.name}` };
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "groq",
|
|
168
|
+
name: "Groq (meeting transcription)",
|
|
169
|
+
env: [
|
|
170
|
+
{ name: "GROQ_API_KEY", secret: true },
|
|
171
|
+
{ name: "GROQ_WHISPER_MODEL", secret: false, optional: true, note: "default whisper-large-v3-turbo" },
|
|
172
|
+
{ name: "GROQ_MAX_FILE_MB", secret: false, optional: true, note: "25 on the free tier" },
|
|
173
|
+
],
|
|
174
|
+
url: "https://console.groq.com/keys",
|
|
175
|
+
steps: ["Create a free account and an API key.", "Recordings are uploaded to Groq only when you confirm a transcription."],
|
|
176
|
+
check: async (env, { fetch: f }) => {
|
|
177
|
+
const { res, body } = await call(f, "https://api.groq.com/openai/v1/models", { headers: bearer(env.GROQ_API_KEY) });
|
|
178
|
+
if (!res.ok) throw rejected(res.status) || new Error(`Groq error: ${body.error?.message || res.status}`);
|
|
179
|
+
const whisper = (body.data || []).some((m) => /whisper/i.test(m.id));
|
|
180
|
+
return { detail: whisper ? "key works, Whisper available" : "key works" };
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "stripe",
|
|
185
|
+
name: "Stripe",
|
|
186
|
+
env: [{ name: "STRIPE_API_KEY", secret: true }, { name: "STRIPE_ALLOW_LIVE", secret: false, optional: true, note: "true allows live keys; leave unset while testing" }, { name: "STRIPE_DEFAULT_CURRENCY", secret: false, optional: true, note: "e.g. usd" }],
|
|
187
|
+
url: "https://dashboard.stripe.com/test/apikeys",
|
|
188
|
+
steps: ["Turn on Test mode in the dashboard, open Developers, API keys.", "Copy the secret key (sk_test_...) or a restricted key (rk_test_...). Live keys are refused unless STRIPE_ALLOW_LIVE=true."],
|
|
189
|
+
check: async (env, { fetch: f }) => {
|
|
190
|
+
const { res, body } = await call(f, "https://api.stripe.com/v1/account", { headers: bearer(env.STRIPE_API_KEY) });
|
|
191
|
+
if (!res.ok) throw rejected(res.status) || new Error(`Stripe error: ${body.error?.message || res.status}`);
|
|
192
|
+
const live = /_live_/.test(env.STRIPE_API_KEY);
|
|
193
|
+
return { detail: `${live ? "LIVE" : "test"} mode account ${body.id}` };
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "discord",
|
|
198
|
+
name: "Discord",
|
|
199
|
+
env: [{ name: "DISCORD_BOT_TOKEN", secret: true }],
|
|
200
|
+
url: "https://discord.com/developers/applications",
|
|
201
|
+
steps: ["New Application, Bot, Reset Token, copy the token.", "Turn on Message Content Intent on the Bot page.", "OAuth2, URL Generator: scope bot with View Channels, Read Message History, Send Messages; open the URL to invite the bot."],
|
|
202
|
+
check: async (env, { fetch: f }) => {
|
|
203
|
+
const { res, body } = await call(f, "https://discord.com/api/v10/users/@me", { headers: { authorization: `Bot ${env.DISCORD_BOT_TOKEN}` } });
|
|
204
|
+
if (!res.ok) throw rejected(res.status, "the bot token") || new Error(`Discord error: ${body.message || res.status}`);
|
|
205
|
+
return { detail: `bot ${body.username}` };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "notion",
|
|
210
|
+
name: "Notion",
|
|
211
|
+
env: [{ name: "NOTION_API_KEY", secret: true }],
|
|
212
|
+
url: "https://www.notion.so/my-integrations",
|
|
213
|
+
steps: ["Create an integration and copy its secret.", "In Notion open each page or database you want available, then ... , Connections, add the integration. Unshared pages stay invisible."],
|
|
214
|
+
check: async (env, { fetch: f }) => {
|
|
215
|
+
const { res, body } = await call(f, "https://api.notion.com/v1/users/me", { headers: { ...bearer(env.NOTION_API_KEY), "notion-version": "2022-06-28" } });
|
|
216
|
+
if (!res.ok) throw rejected(res.status) || new Error(`Notion error: ${body.message || res.status}`);
|
|
217
|
+
return { detail: `integration ${body.name || body.id}` };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "sentry",
|
|
222
|
+
name: "Sentry",
|
|
223
|
+
env: [{ name: "SENTRY_AUTH_TOKEN", secret: true }],
|
|
224
|
+
url: "https://sentry.io/settings/account/api/auth-tokens/",
|
|
225
|
+
steps: ["Create an auth token with project:read and event:read (add event:write to resolve, ignore or comment on issues)."],
|
|
226
|
+
check: async (env, { fetch: f }) => {
|
|
227
|
+
let last;
|
|
228
|
+
for (const host of ["sentry.io", "de.sentry.io"]) {
|
|
229
|
+
const { res, body } = await call(f, `https://${host}/api/0/organizations/`, { headers: bearer(env.SENTRY_AUTH_TOKEN) });
|
|
230
|
+
if (res.ok) return { detail: `${(body || []).length} organization(s) on ${host}` };
|
|
231
|
+
last = res.status;
|
|
232
|
+
if (res.status === 401 || res.status === 403) break;
|
|
233
|
+
}
|
|
234
|
+
throw rejected(last) || new Error(`Sentry error: HTTP ${last}`);
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: "vercel",
|
|
239
|
+
name: "Vercel",
|
|
240
|
+
env: [{ name: "VERCEL_TOKEN", secret: true }],
|
|
241
|
+
url: "https://vercel.com/account/tokens",
|
|
242
|
+
steps: ["Create a token and copy it."],
|
|
243
|
+
check: async (env, { fetch: f }) => {
|
|
244
|
+
const { res, body } = await call(f, "https://api.vercel.com/v2/user", { headers: bearer(env.VERCEL_TOKEN) });
|
|
245
|
+
if (!res.ok) throw rejected(res.status) || new Error(`Vercel error: ${body.error?.message || res.status}`);
|
|
246
|
+
return { detail: `signed in as ${body.user?.username || body.user?.email}` };
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "supabase",
|
|
251
|
+
name: "Supabase",
|
|
252
|
+
env: [{ name: "SUPABASE_ACCESS_TOKEN", secret: true }],
|
|
253
|
+
url: "https://supabase.com/dashboard/account/tokens",
|
|
254
|
+
steps: ["Create an access token and copy it."],
|
|
255
|
+
check: async (env, { fetch: f }) => {
|
|
256
|
+
const { res, body } = await call(f, "https://api.supabase.com/v1/projects", { headers: bearer(env.SUPABASE_ACCESS_TOKEN) });
|
|
257
|
+
if (!res.ok) throw rejected(res.status) || new Error(`Supabase error: ${body.message || res.status}`);
|
|
258
|
+
return { detail: `${Array.isArray(body) ? body.length : 0} project(s)` };
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
export const findService = (ref, services = SERVICES) => {
|
|
264
|
+
const s = String(ref ?? "").trim().toLowerCase();
|
|
265
|
+
return services.find((x) => x.id === s || x.name.toLowerCase() === s || x.name.toLowerCase().startsWith(`${s} `));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export const requiredNames = (svc) => svc.env.filter((v) => !v.optional).map((v) => v.name);
|
|
269
|
+
export const allNames = (services = SERVICES) => services.flatMap((s) => s.env.map((v) => v.name));
|
|
270
|
+
|
|
271
|
+
/** configured: every required setting present. incomplete: some but not all. */
|
|
272
|
+
export function serviceState(svc, env = process.env) {
|
|
273
|
+
const req = requiredNames(svc);
|
|
274
|
+
const have = req.filter((n) => env[n]);
|
|
275
|
+
return { configured: have.length === req.length, incomplete: have.length > 0 && have.length < req.length, missing: req.filter((n) => !env[n]) };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Run a service's live check. Never throws; the message has secrets removed. */
|
|
279
|
+
export async function checkService(svc, env = process.env, deps = {}) {
|
|
280
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
281
|
+
try {
|
|
282
|
+
const r = await svc.check(env, { fetch: fetchFn });
|
|
283
|
+
return { id: svc.id, name: svc.name, ok: true, detail: r.detail };
|
|
284
|
+
} catch (err) {
|
|
285
|
+
return { id: svc.id, name: svc.name, ok: false, detail: scrub(err.message, env, svc.env.map((v) => v.name)) };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** When a tool error mentions one of a service's settings, say how to fix it. */
|
|
290
|
+
export function setupHint(message, services = SERVICES) {
|
|
291
|
+
const text = String(message ?? "");
|
|
292
|
+
if (/invalid_grant|expired or revoked/i.test(text)) return 'Fix: the Google sign-in expired. Run "causly-server login google" in a terminal.';
|
|
293
|
+
const svc = services.find((s) => s.env.some((v) => !v.optional && text.includes(v.name)));
|
|
294
|
+
return svc ? `Fix: run "causly-server setup ${svc.id}" in a terminal. New keys are picked up on the next tool call.` : null;
|
|
295
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Logic behind `causly-server setup`, `keys` and `doctor --test`.
|
|
2
|
+
// Keys go into one settings file; they are never printed in full, logged, or sent anywhere
|
|
3
|
+
// except to the service they belong to (for the live check).
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import { SERVICES, findService, serviceState, checkService, allNames } from "./services.js";
|
|
9
|
+
import { setEnvValue, unsetEnvValue, readEnvValues, describeSecret, isValidName, checkValue } from "./envFile.js";
|
|
10
|
+
import { envWritePath } from "./paths.js";
|
|
11
|
+
import { runGoogleLogin } from "./googleLogin.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------- setup wizard
|
|
14
|
+
|
|
15
|
+
async function findTelegramChats(token, fetchFn) {
|
|
16
|
+
const res = await fetchFn(`https://api.telegram.org/bot${token}/getUpdates?limit=50`, { signal: AbortSignal.timeout(10_000) });
|
|
17
|
+
const body = await res.json().catch(() => ({}));
|
|
18
|
+
const seen = new Map();
|
|
19
|
+
for (const u of body.result || []) {
|
|
20
|
+
const chat = u.message?.chat || u.my_chat_member?.chat;
|
|
21
|
+
if (chat?.type === "private") seen.set(chat.id, [chat.first_name, chat.last_name].filter(Boolean).join(" ") || chat.username || String(chat.id));
|
|
22
|
+
}
|
|
23
|
+
return [...seen].map(([id, name]) => ({ id: String(id), name }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Walk the user through adding keys. Each service: show where to get the key, ask for it
|
|
28
|
+
* (hidden), test it live, and save it only when the test passes (or the user insists).
|
|
29
|
+
*/
|
|
30
|
+
export async function runSetup({ service, term, env = process.env, file = envWritePath(), fetch: fetchFn = globalThis.fetch, services = SERVICES, login = runGoogleLogin } = {}) {
|
|
31
|
+
const say = (t = "") => term.write(`${t}\n`);
|
|
32
|
+
const result = { saved: [], saved_untested: [], skipped: [] };
|
|
33
|
+
|
|
34
|
+
async function ask(v) {
|
|
35
|
+
const label = v.optional ? `${v.name} (optional${v.note ? `, ${v.note}` : ""}; Enter to skip)` : v.name;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const raw = (await (v.secret ? term.secret(`${label}: `) : term.line(`${label}: `))).trim();
|
|
38
|
+
if (!raw) return "";
|
|
39
|
+
try {
|
|
40
|
+
return checkValue(raw);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
say(`${err.message} Try again.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function pickChat(token) {
|
|
48
|
+
say("Open Telegram, find your bot and press Start (or send it any message).");
|
|
49
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
50
|
+
await term.line("Press Enter when you have done that: ");
|
|
51
|
+
const chats = await findTelegramChats(token, fetchFn).catch(() => []);
|
|
52
|
+
if (chats.length === 1) {
|
|
53
|
+
if (await term.confirm(`Send alerts to the chat with ${chats[0].name} (id ${chats[0].id})?`)) return chats[0].id;
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
if (chats.length > 1) {
|
|
57
|
+
chats.forEach((c, i) => say(` ${i + 1}. ${c.name} (id ${c.id})`));
|
|
58
|
+
const n = Number((await term.line("Which chat? Number, or Enter to skip: ")).trim());
|
|
59
|
+
return chats[n - 1]?.id || "";
|
|
60
|
+
}
|
|
61
|
+
say("The bot has not seen a message yet.");
|
|
62
|
+
}
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function configure(svc) {
|
|
67
|
+
say(`\n== ${svc.name} ==`);
|
|
68
|
+
if (svc.login && term.interactive && (await term.confirm("Sign in with your browser? (recommended, no copying of tokens)"))) {
|
|
69
|
+
try {
|
|
70
|
+
const r = await login({ targets: [svc.login], term, env, file, fetch: fetchFn });
|
|
71
|
+
(r.saved.includes(svc.login) ? result.saved : result.skipped).push(svc.id);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
say(`Sign-in failed: ${err.message}`);
|
|
74
|
+
result.skipped.push(svc.id);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
svc.steps.forEach((s, i) => say(` ${i + 1}. ${s}`));
|
|
79
|
+
say(` Link: ${svc.url}\n`);
|
|
80
|
+
|
|
81
|
+
const pending = {};
|
|
82
|
+
for (const v of svc.env) {
|
|
83
|
+
if (v.discover) continue;
|
|
84
|
+
if (env[v.name]) {
|
|
85
|
+
const shown = v.secret ? describeSecret(env[v.name]) : `set (${env[v.name]})`;
|
|
86
|
+
if (!(await term.confirm(`${v.name} is already ${shown}. Replace it?`, false))) continue;
|
|
87
|
+
}
|
|
88
|
+
const val = await ask(v);
|
|
89
|
+
if (!val) {
|
|
90
|
+
if (!v.optional) {
|
|
91
|
+
say("Skipped.");
|
|
92
|
+
result.skipped.push(svc.id);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
pending[v.name] = val;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const merged = { ...env, ...pending };
|
|
101
|
+
// A setting that is found automatically after the key works (Telegram's chat id) is not needed to test the key.
|
|
102
|
+
const missing = serviceState(svc, merged).missing.filter((n) => !svc.env.find((v) => v.name === n)?.discover);
|
|
103
|
+
if (missing.length) {
|
|
104
|
+
say(`Still missing: ${missing.join(", ")}. Nothing saved.`);
|
|
105
|
+
result.skipped.push(svc.id);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let untested = false;
|
|
110
|
+
say("Testing...");
|
|
111
|
+
const r = await checkService(svc, merged, { fetch: fetchFn });
|
|
112
|
+
if (r.ok) say(`ok ${r.detail}`);
|
|
113
|
+
else {
|
|
114
|
+
say(`FAILED: ${r.detail}`);
|
|
115
|
+
const choice = (await term.line("Save anyway (s), enter again (r), or skip (Enter)? ")).trim().toLowerCase();
|
|
116
|
+
if (choice === "r") return configure(svc);
|
|
117
|
+
if (choice !== "s") {
|
|
118
|
+
say("Nothing saved.");
|
|
119
|
+
result.skipped.push(svc.id);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
untested = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const discoverVar = svc.env.find((v) => v.discover);
|
|
126
|
+
if (r.ok && discoverVar && !merged[discoverVar.name] && svc.id === "telegram") {
|
|
127
|
+
const id = await pickChat(merged.TELEGRAM_BOT_TOKEN);
|
|
128
|
+
if (id) pending[discoverVar.name] = id;
|
|
129
|
+
else say(`No chat chosen. Alerts need ${discoverVar.name}; add it later with: causly-server keys set ${discoverVar.name}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const [name, value] of Object.entries(pending)) {
|
|
133
|
+
setEnvValue(file, name, value);
|
|
134
|
+
env[name] = value;
|
|
135
|
+
}
|
|
136
|
+
say(`Saved to ${file}. Running tools pick it up on their next call.`);
|
|
137
|
+
(untested ? result.saved_untested : result.saved).push(svc.id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (service) {
|
|
141
|
+
const svc = findService(service, services);
|
|
142
|
+
if (!svc) throw new Error(`Unknown service "${service}". Choose from: ${services.map((s) => s.id).join(", ")}.`);
|
|
143
|
+
await configure(svc);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
say(`Settings file: ${file}`);
|
|
148
|
+
for (;;) {
|
|
149
|
+
say("\nServices:");
|
|
150
|
+
services.forEach((s, i) => {
|
|
151
|
+
const st = serviceState(s, env);
|
|
152
|
+
say(` ${String(i + 1).padStart(2)}. ${s.name.padEnd(30)} ${st.configured ? "set up" : st.incomplete ? `incomplete (missing ${st.missing.join(", ")})` : "-"}`);
|
|
153
|
+
});
|
|
154
|
+
const answer = (await term.line("\nWhich ones? Numbers separated by commas, or Enter to finish: ")).trim();
|
|
155
|
+
if (!answer) break;
|
|
156
|
+
const picks = [...new Set(answer.split(/[,\s]+/).map(Number))].filter((n) => Number.isInteger(n) && n >= 1 && n <= services.length);
|
|
157
|
+
if (!picks.length) {
|
|
158
|
+
say("Type the numbers from the list, for example: 1,3");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const n of picks) await configure(services[n - 1]);
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------- keys
|
|
167
|
+
|
|
168
|
+
export function keysList({ env = process.env, file = envWritePath(), services = SERVICES } = {}) {
|
|
169
|
+
const known = new Set(allNames(services));
|
|
170
|
+
const rows = [];
|
|
171
|
+
for (const s of services) {
|
|
172
|
+
for (const v of s.env) {
|
|
173
|
+
const value = env[v.name];
|
|
174
|
+
rows.push({
|
|
175
|
+
service: s.id,
|
|
176
|
+
name: v.name,
|
|
177
|
+
optional: Boolean(v.optional),
|
|
178
|
+
secret: Boolean(v.secret),
|
|
179
|
+
status: value ? (v.secret ? describeSecret(value) : `set (${value})`) : v.optional ? "-" : "missing",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const other = Object.keys(readEnvValues(file)).filter((n) => !known.has(n));
|
|
184
|
+
return { file, rows, other };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function formatKeysList({ file, rows, other }) {
|
|
188
|
+
const lines = [`Settings file: ${file}`, ""];
|
|
189
|
+
let current = "";
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
if (r.service !== current) {
|
|
192
|
+
current = r.service;
|
|
193
|
+
lines.push(`${current}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push(` ${r.name.padEnd(26)} ${r.status}${r.optional ? " (optional)" : ""}`);
|
|
196
|
+
}
|
|
197
|
+
if (other.length) lines.push("", `Other settings in the file: ${other.join(", ")}`);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function keysSet({ name, term, file = envWritePath(), env = process.env, value, launchEnv = {}, services = SERVICES } = {}) {
|
|
202
|
+
if (!name || !isValidName(name)) throw new Error("Give a setting name in capital letters, for example: causly-server keys set TELEGRAM_BOT_TOKEN");
|
|
203
|
+
let v = value;
|
|
204
|
+
if (v === undefined) v = term.interactive ? await term.secret(`${name}: `) : await term.readPipedLine();
|
|
205
|
+
v = checkValue(v);
|
|
206
|
+
const r = setEnvValue(file, name, v);
|
|
207
|
+
env[name] = v;
|
|
208
|
+
const notes = [];
|
|
209
|
+
if (!allNames(services).includes(name) && !name.startsWith("CAUSLY_")) notes.push(`${name} is not a setting this server knows; saved anyway.`);
|
|
210
|
+
if (launchEnv[name] !== undefined && launchEnv[name] !== v) notes.push(`${name} is also set in the environment your editor or terminal starts with, and that value takes priority over this file.`);
|
|
211
|
+
return { name, file, created: r.created, replaced: r.replaced, notes };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function keysUnset({ name, file = envWritePath(), env = process.env, launchEnv = {} } = {}) {
|
|
215
|
+
if (!name || !isValidName(name)) throw new Error("Give a setting name in capital letters, for example: causly-server keys unset TELEGRAM_BOT_TOKEN");
|
|
216
|
+
const removed = unsetEnvValue(file, name);
|
|
217
|
+
if (removed && launchEnv[name] === undefined) delete env[name];
|
|
218
|
+
const notes = launchEnv[name] !== undefined ? [`${name} is still set in the environment your editor or terminal starts with.`] : [];
|
|
219
|
+
return { name, file, removed, notes };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Open the settings file in an editor, creating it first if needed. */
|
|
223
|
+
export function keysOpen({ file = envWritePath(), platform = os.platform(), exec = execFile } = {}) {
|
|
224
|
+
if (!fs.existsSync(file)) {
|
|
225
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
226
|
+
fs.writeFileSync(file, "# causly-server settings: NAME=value, one per line\n", { mode: 0o600 });
|
|
227
|
+
}
|
|
228
|
+
const [cmd, args] = platform === "win32" ? ["notepad.exe", [file]] : platform === "darwin" ? ["open", ["-t", file]] : ["xdg-open", [file]];
|
|
229
|
+
exec(cmd, args, { windowsHide: false }, () => {});
|
|
230
|
+
return { file, opened_with: cmd };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------- doctor --test
|
|
234
|
+
|
|
235
|
+
/** Live-check every service that has its settings. Incomplete ones are reported, unconfigured ones skipped. */
|
|
236
|
+
export async function testConfigured({ env = process.env, fetch: fetchFn = globalThis.fetch, services = SERVICES } = {}) {
|
|
237
|
+
const jobs = [];
|
|
238
|
+
const results = [];
|
|
239
|
+
for (const s of services) {
|
|
240
|
+
const st = serviceState(s, env);
|
|
241
|
+
if (st.configured) jobs.push(checkService(s, env, { fetch: fetchFn }));
|
|
242
|
+
else if (st.incomplete) results.push({ id: s.id, name: s.name, ok: false, detail: `incomplete: missing ${st.missing.join(", ")}` });
|
|
243
|
+
}
|
|
244
|
+
return [...(await Promise.all(jobs)), ...results];
|
|
245
|
+
}
|