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,151 @@
|
|
|
1
|
+
const SLACK_API = "https://slack.com/api";
|
|
2
|
+
|
|
3
|
+
function getToken() {
|
|
4
|
+
const token = process.env.SLACK_BOT_TOKEN;
|
|
5
|
+
if (!token) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"SLACK_BOT_TOKEN not set. Run `npm run setup` to configure it, or add it manually to your .env file: SLACK_BOT_TOKEN=xoxb-..."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return token;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function slackFetch(method, body = {}, useGet = false) {
|
|
14
|
+
const token = getToken();
|
|
15
|
+
|
|
16
|
+
let res;
|
|
17
|
+
if (useGet) {
|
|
18
|
+
const query = new URLSearchParams(
|
|
19
|
+
Object.entries(body).reduce((acc, [k, v]) => {
|
|
20
|
+
if (v !== undefined && v !== null) acc[k] = String(v);
|
|
21
|
+
return acc;
|
|
22
|
+
}, {})
|
|
23
|
+
).toString();
|
|
24
|
+
res = await fetch(`${SLACK_API}/${method}${query ? `?${query}` : ""}`, {
|
|
25
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
26
|
+
});
|
|
27
|
+
} else {
|
|
28
|
+
res = await fetch(`${SLACK_API}/${method}`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${token}`,
|
|
32
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
if (!data.ok) {
|
|
40
|
+
throw new Error(`Slack API error (${method}): ${data.error || "unknown_error"}`);
|
|
41
|
+
}
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Look up a Slack user's info by user ID. */
|
|
46
|
+
export async function slackGetUser({ user_id }) {
|
|
47
|
+
const data = await slackFetch("users.info", { user: user_id }, true);
|
|
48
|
+
const u = data.user;
|
|
49
|
+
return {
|
|
50
|
+
id: u.id,
|
|
51
|
+
name: u.name,
|
|
52
|
+
real_name: u.real_name,
|
|
53
|
+
display_name: u.profile?.display_name || null,
|
|
54
|
+
email: u.profile?.email || null,
|
|
55
|
+
is_bot: u.is_bot,
|
|
56
|
+
is_admin: u.is_admin || false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** List channels in the workspace. Paginates automatically up to `limit` total channels. */
|
|
61
|
+
export async function slackListChannels({ limit = 100, types = "public_channel,private_channel" }) {
|
|
62
|
+
const channels = [];
|
|
63
|
+
let cursor;
|
|
64
|
+
do {
|
|
65
|
+
const data = await slackFetch(
|
|
66
|
+
"conversations.list",
|
|
67
|
+
{ limit: Math.min(200, limit - channels.length), types, cursor },
|
|
68
|
+
true
|
|
69
|
+
);
|
|
70
|
+
channels.push(...data.channels);
|
|
71
|
+
cursor = data.response_metadata?.next_cursor || undefined;
|
|
72
|
+
} while (cursor && channels.length < limit);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
channels: channels.slice(0, limit).map((c) => ({
|
|
76
|
+
id: c.id,
|
|
77
|
+
name: c.name,
|
|
78
|
+
is_private: c.is_private,
|
|
79
|
+
is_archived: c.is_archived,
|
|
80
|
+
num_members: c.num_members,
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get details of a single channel. */
|
|
86
|
+
export async function slackGetChannel({ channel_id }) {
|
|
87
|
+
const data = await slackFetch("conversations.info", { channel: channel_id }, true);
|
|
88
|
+
const c = data.channel;
|
|
89
|
+
return {
|
|
90
|
+
id: c.id,
|
|
91
|
+
name: c.name,
|
|
92
|
+
is_private: c.is_private,
|
|
93
|
+
is_archived: c.is_archived,
|
|
94
|
+
topic: c.topic?.value || null,
|
|
95
|
+
purpose: c.purpose?.value || null,
|
|
96
|
+
num_members: c.num_members,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read recent message history from a channel. */
|
|
101
|
+
export async function slackReadMessages({ channel_id, limit = 20 }) {
|
|
102
|
+
const data = await slackFetch("conversations.history", { channel: channel_id, limit }, true);
|
|
103
|
+
return {
|
|
104
|
+
channel_id,
|
|
105
|
+
messages: data.messages.map((m) => ({
|
|
106
|
+
user: m.user || m.bot_id || "unknown",
|
|
107
|
+
text: m.text,
|
|
108
|
+
ts: m.ts,
|
|
109
|
+
thread_ts: m.thread_ts || null,
|
|
110
|
+
reply_count: m.reply_count || 0,
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Search messages across the workspace. Requires a user token with search:read scope; bot tokens generally can't search — this will surface a clear error if unsupported. */
|
|
116
|
+
export async function slackSearchMessages({ query, count = 20 }) {
|
|
117
|
+
const data = await slackFetch("search.messages", { query, count }, true);
|
|
118
|
+
return {
|
|
119
|
+
query,
|
|
120
|
+
total: data.messages?.total || 0,
|
|
121
|
+
matches: (data.messages?.matches || []).map((m) => ({
|
|
122
|
+
channel: m.channel?.name || m.channel?.id,
|
|
123
|
+
user: m.username || m.user,
|
|
124
|
+
text: m.text,
|
|
125
|
+
ts: m.ts,
|
|
126
|
+
permalink: m.permalink,
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Post a message to a channel. HIGH risk — requires confirm: true. */
|
|
132
|
+
export async function slackSendMessage({ channel_id, text, confirm }) {
|
|
133
|
+
const data = await slackFetch("chat.postMessage", { channel: channel_id, text });
|
|
134
|
+
return { channel: data.channel, ts: data.ts, sent: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Reply in a thread. HIGH risk — requires confirm: true. */
|
|
138
|
+
export async function slackReplyThread({ channel_id, thread_ts, text, confirm }) {
|
|
139
|
+
const data = await slackFetch("chat.postMessage", {
|
|
140
|
+
channel: channel_id,
|
|
141
|
+
thread_ts,
|
|
142
|
+
text,
|
|
143
|
+
});
|
|
144
|
+
return { channel: data.channel, ts: data.ts, thread_ts, sent: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Create a new channel. HIGH risk — requires confirm: true. */
|
|
148
|
+
export async function slackCreateChannel({ name, is_private = false, confirm }) {
|
|
149
|
+
const data = await slackFetch("conversations.create", { name, is_private });
|
|
150
|
+
return { id: data.channel.id, name: data.channel.name, is_private: data.channel.is_private, created: true };
|
|
151
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `causly-server status`: one screen with what is set up and what is waiting.
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { SERVICES, serviceState } from "./services.js";
|
|
4
|
+
import { CLIENTS, SERVER_NAME, clientConfigPath, readJson } from "./cliOps.js";
|
|
5
|
+
import { dbPath } from "./store.js";
|
|
6
|
+
import { latestBackup } from "./backupOps.js";
|
|
7
|
+
import { checkForUpdate, updateMessage } from "./updateCheck.js";
|
|
8
|
+
import { PACKS } from "../packs/registry.js";
|
|
9
|
+
|
|
10
|
+
const CLIENT_LABEL = { claude: "Claude Desktop", cursor: "Cursor", windsurf: "Windsurf" };
|
|
11
|
+
|
|
12
|
+
async function readData(dbFile, now) {
|
|
13
|
+
if (!fs.existsSync(dbFile)) return null;
|
|
14
|
+
let sqlite;
|
|
15
|
+
try {
|
|
16
|
+
sqlite = await import("node:sqlite");
|
|
17
|
+
} catch {
|
|
18
|
+
return { unavailable: true };
|
|
19
|
+
}
|
|
20
|
+
let db;
|
|
21
|
+
try {
|
|
22
|
+
db = new sqlite.DatabaseSync(dbFile, { readOnly: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return { unavailable: true };
|
|
25
|
+
}
|
|
26
|
+
const one = (sql, ...args) => {
|
|
27
|
+
try {
|
|
28
|
+
return db.prepare(sql).get(...args);
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // that pack has never been used, so its table does not exist yet
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const today = now.toISOString().slice(0, 10);
|
|
35
|
+
return {
|
|
36
|
+
companies: one("SELECT COUNT(*) AS n FROM companies")?.n ?? 0,
|
|
37
|
+
open_deals: one("SELECT COUNT(*) AS n FROM deals WHERE closed_at IS NULL")?.n ?? 0,
|
|
38
|
+
meetings: one("SELECT COUNT(*) AS n FROM meetings")?.n ?? 0,
|
|
39
|
+
open_actions: one("SELECT COUNT(*) AS n FROM action_items WHERE status = 'open'")?.n ?? 0,
|
|
40
|
+
overdue_actions: one("SELECT COUNT(*) AS n FROM action_items WHERE status = 'open' AND due_date IS NOT NULL AND due_date < ?", today)?.n ?? 0,
|
|
41
|
+
pending_reminders: one("SELECT COUNT(*) AS n FROM reminders WHERE status = 'pending'")?.n ?? 0,
|
|
42
|
+
overdue_reminders: one("SELECT COUNT(*) AS n FROM reminders WHERE status = 'pending' AND due_at <= ?", now.toISOString())?.n ?? 0,
|
|
43
|
+
next_reminder: one("SELECT title, due_at FROM reminders WHERE status = 'pending' ORDER BY due_at LIMIT 1") ?? null,
|
|
44
|
+
};
|
|
45
|
+
} finally {
|
|
46
|
+
db.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (ms, now) => {
|
|
51
|
+
const days = Math.floor((now - ms) / 86_400_000);
|
|
52
|
+
return days <= 0 ? "today" : days === 1 ? "yesterday" : `${days} days ago`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function runStatus({ env = process.env, version, platform, home, dbFile = dbPath(), now = new Date(), fetch: fetchFn, checkUpdate = true } = {}) {
|
|
56
|
+
const where = { platform, home, env };
|
|
57
|
+
const lines = [`causly-server ${version}`, ""];
|
|
58
|
+
|
|
59
|
+
// editors
|
|
60
|
+
const editors = CLIENTS.map((c) => {
|
|
61
|
+
const { config } = readJson(clientConfigPath(c, where));
|
|
62
|
+
const entry = config.mcpServers?.[SERVER_NAME];
|
|
63
|
+
return entry ? { client: c, entry } : null;
|
|
64
|
+
}).filter(Boolean);
|
|
65
|
+
if (!editors.length) lines.push('Editors: none yet. Run "causly-server init".');
|
|
66
|
+
for (const { client, entry } of editors) {
|
|
67
|
+
const requested = String(entry.env?.CAUSLY_TOOLSETS || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
68
|
+
const core = Object.keys(PACKS).filter((n) => PACKS[n].core);
|
|
69
|
+
const packs = requested.includes("all") ? Object.keys(PACKS).length : new Set([...core, ...requested.filter((n) => PACKS[n])]).size;
|
|
70
|
+
const workspace = entry.env?.CAUSLY_WORKSPACE ? `files limited to ${entry.env.CAUSLY_WORKSPACE}` : "file access not limited to a folder";
|
|
71
|
+
lines.push(`Editors: ${CLIENT_LABEL[client]} (${packs} tool packs at startup; ${workspace})`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// keys
|
|
75
|
+
const ready = SERVICES.filter((s) => serviceState(s, env).configured).map((s) => s.name);
|
|
76
|
+
const partial = SERVICES.filter((s) => serviceState(s, env).incomplete).map((s) => s.name);
|
|
77
|
+
lines.push(`Keys: ${ready.length ? `${ready.length} ready (${ready.join(", ")})` : "none yet"}`);
|
|
78
|
+
if (partial.length) lines.push(` incomplete: ${partial.join(", ")}`);
|
|
79
|
+
lines.push(' add or fix with "causly-server setup", test with "causly-server doctor --test"');
|
|
80
|
+
|
|
81
|
+
// data
|
|
82
|
+
const data = await readData(dbFile, now);
|
|
83
|
+
lines.push("");
|
|
84
|
+
if (!data) lines.push("Data: nothing saved yet (pipeline, meetings and reminders appear here once used)");
|
|
85
|
+
else if (data.unavailable) lines.push("Data: cannot be read here (needs Node 22.13 or newer)");
|
|
86
|
+
else {
|
|
87
|
+
lines.push(`Pipeline: ${data.open_deals} open deal(s), ${data.companies} companies`);
|
|
88
|
+
lines.push(`Meetings: ${data.meetings} saved; ${data.open_actions} open action item(s)${data.overdue_actions ? `, ${data.overdue_actions} overdue` : ""}`);
|
|
89
|
+
const next = data.next_reminder ? `; next: "${data.next_reminder.title}" at ${new Date(data.next_reminder.due_at).toLocaleString()}` : "";
|
|
90
|
+
lines.push(`Reminders: ${data.pending_reminders} pending${data.overdue_reminders ? ` (${data.overdue_reminders} due now)` : ""}${next}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const backup = data && !data.unavailable ? latestBackup() : null;
|
|
94
|
+
if (data && !data.unavailable) {
|
|
95
|
+
lines.push(backup ? `Backup: last one ${ago(backup.mtime, now.getTime())}` : 'Backup: none yet. Make one with "causly-server backup"');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// update
|
|
99
|
+
if (checkUpdate) {
|
|
100
|
+
const msg = updateMessage(await checkForUpdate({ current: version, fetch: fetchFn, env }));
|
|
101
|
+
if (msg) lines.push("", msg);
|
|
102
|
+
}
|
|
103
|
+
return { lines, editors: editors.map((e) => e.client), ready, partial, data, backup };
|
|
104
|
+
}
|
package/tools/store.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Local SQLite store shared by packs that need to remember things between
|
|
2
|
+
// sessions (reminders, pipeline, follow-ups, ...).
|
|
3
|
+
//
|
|
4
|
+
// - Uses Node's built-in `node:sqlite` (no native dependency). Needs Node 22.13+;
|
|
5
|
+
// the rest of the server keeps working on older Node, only store-backed
|
|
6
|
+
// tools report a clear error.
|
|
7
|
+
// - Opened lazily on first use, so users who never touch these tools never get a file.
|
|
8
|
+
// - Location: ~/.causly/causly.db, or CAUSLY_DB_PATH.
|
|
9
|
+
// - Each pack owns its tables through registerMigrations(namespace, [sql, ...]).
|
|
10
|
+
// Migrations are append-only: never edit a shipped statement, add a new one.
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import os from "os";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { causlyHome } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
const migrations = new Map(); // namespace -> SQL statements, in order
|
|
17
|
+
const applied = new Map(); // namespace -> highest applied version, for the open db
|
|
18
|
+
let db = null;
|
|
19
|
+
let openPath = null;
|
|
20
|
+
let opening = null; // { file, promise }
|
|
21
|
+
|
|
22
|
+
export function registerMigrations(namespace, statements) {
|
|
23
|
+
migrations.set(namespace, statements);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function dbPath() {
|
|
27
|
+
return process.env.CAUSLY_DB_PATH || path.join(causlyHome(), "causly.db");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function openDatabase(file) {
|
|
31
|
+
let sqlite;
|
|
32
|
+
try {
|
|
33
|
+
sqlite = await import("node:sqlite");
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`The local store needs Node 22.13 or newer (this is ${process.version}).`);
|
|
36
|
+
}
|
|
37
|
+
if (file !== ":memory:") fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
const handle = new sqlite.DatabaseSync(file);
|
|
39
|
+
handle.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); // wait for another process's write instead of failing
|
|
40
|
+
handle.exec(
|
|
41
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations (" +
|
|
42
|
+
"namespace TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL, " +
|
|
43
|
+
"PRIMARY KEY (namespace, version))"
|
|
44
|
+
);
|
|
45
|
+
return handle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function migrate(handle) {
|
|
49
|
+
for (const [namespace, statements] of migrations) {
|
|
50
|
+
let current = applied.get(namespace);
|
|
51
|
+
if (current === undefined) {
|
|
52
|
+
current = handle
|
|
53
|
+
.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM schema_migrations WHERE namespace = ?")
|
|
54
|
+
.get(namespace).v;
|
|
55
|
+
}
|
|
56
|
+
for (let v = current; v < statements.length; v++) {
|
|
57
|
+
handle.exec("BEGIN");
|
|
58
|
+
try {
|
|
59
|
+
handle.exec(statements[v]);
|
|
60
|
+
handle
|
|
61
|
+
.prepare("INSERT INTO schema_migrations (namespace, version, applied_at) VALUES (?, ?, ?)")
|
|
62
|
+
.run(namespace, v + 1, new Date().toISOString());
|
|
63
|
+
handle.exec("COMMIT");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
handle.exec("ROLLBACK");
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
current = v + 1;
|
|
69
|
+
}
|
|
70
|
+
applied.set(namespace, current);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The open database, with every registered migration applied. */
|
|
75
|
+
export async function getDb() {
|
|
76
|
+
const file = dbPath();
|
|
77
|
+
if (!db || openPath !== file) {
|
|
78
|
+
if (!opening || opening.file !== file) {
|
|
79
|
+
closeDb();
|
|
80
|
+
const promise = openDatabase(file).then(
|
|
81
|
+
(handle) => {
|
|
82
|
+
db = handle;
|
|
83
|
+
openPath = file;
|
|
84
|
+
applied.clear();
|
|
85
|
+
opening = null;
|
|
86
|
+
},
|
|
87
|
+
(err) => {
|
|
88
|
+
opening = null;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
opening = { file, promise };
|
|
93
|
+
}
|
|
94
|
+
await opening.promise;
|
|
95
|
+
}
|
|
96
|
+
migrate(db);
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function closeDb() {
|
|
101
|
+
if (db) {
|
|
102
|
+
try {
|
|
103
|
+
db.close();
|
|
104
|
+
} catch {
|
|
105
|
+
// already closed
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
db = null;
|
|
109
|
+
openPath = null;
|
|
110
|
+
applied.clear();
|
|
111
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Stripe tools over the REST API. No new dependencies.
|
|
2
|
+
//
|
|
3
|
+
// stripe_get_account balance, and whether the key is test or live
|
|
4
|
+
// stripe_list_customers find customers by email or name
|
|
5
|
+
// stripe_create_customer add a customer
|
|
6
|
+
// stripe_list_invoices invoices, filter by customer and status
|
|
7
|
+
// stripe_create_invoice DRAFT invoice with line items (nothing is sent or charged)
|
|
8
|
+
// stripe_send_invoice finalize a draft and email it to the customer (HIGH, confirm)
|
|
9
|
+
// stripe_create_payment_link shareable checkout link for a fixed amount (HIGH, confirm)
|
|
10
|
+
// stripe_list_payments recent payments
|
|
11
|
+
// stripe_refund refund a payment, whole or partial (HIGH, confirm)
|
|
12
|
+
//
|
|
13
|
+
// Credentials: STRIPE_API_KEY. Use a TEST key (sk_test_... or a restricted rk_test_...) first.
|
|
14
|
+
// A live key is refused unless STRIPE_ALLOW_LIVE=true, so a live key pasted by mistake does nothing.
|
|
15
|
+
// Amounts in and out are in normal units: 500 means $500.00 (JPY: 500 means 500 yen).
|
|
16
|
+
import crypto from "crypto";
|
|
17
|
+
import { UNTRUSTED_NOTICE } from "./webOps.js";
|
|
18
|
+
|
|
19
|
+
const API = "https://api.stripe.com/v1";
|
|
20
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
|
|
21
|
+
const EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
|
|
22
|
+
const MAX_MINOR = 99_999_999; // Stripe's limit: 8 digits in the smallest unit
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------- money
|
|
25
|
+
|
|
26
|
+
const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]);
|
|
27
|
+
const THREE_DECIMAL = new Set(["bhd", "iqd", "jod", "kwd", "lyd", "omr", "tnd"]);
|
|
28
|
+
const decimals = (cur) => (ZERO_DECIMAL.has(cur) ? 0 : THREE_DECIMAL.has(cur) ? 3 : 2);
|
|
29
|
+
|
|
30
|
+
export function checkCurrency(value, env = process.env) {
|
|
31
|
+
const c = String(value ?? env.STRIPE_DEFAULT_CURRENCY ?? "usd").trim().toLowerCase();
|
|
32
|
+
if (!/^[a-z]{3}$/.test(c)) throw new Error("currency must be a 3-letter code like usd, eur or inr.");
|
|
33
|
+
return c;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 500 usd -> 50000. Rejects zero, negatives and more decimals than the currency has. */
|
|
37
|
+
export function toMinor(amount, currency, label = "amount") {
|
|
38
|
+
const n = Number(amount);
|
|
39
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`${label} must be a positive number in normal units (500 means 500.00).`);
|
|
40
|
+
const d = decimals(currency);
|
|
41
|
+
const minor = Math.round(n * 10 ** d);
|
|
42
|
+
if (Math.abs(minor / 10 ** d - n) > 1e-9) throw new Error(`${label} ${n} has too many decimals for ${currency.toUpperCase()} (${d} allowed).`);
|
|
43
|
+
if (minor > MAX_MINOR) throw new Error(`${label} is above Stripe's maximum for ${currency.toUpperCase()}.`);
|
|
44
|
+
return minor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const toMajor = (minor, currency) => (minor == null ? null : Math.round(minor) / 10 ** decimals(currency));
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- client
|
|
50
|
+
|
|
51
|
+
/** Nested objects and arrays use Stripe's bracket form: line_items[0][price]=... */
|
|
52
|
+
export function encode(obj) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const walk = (value, key) => {
|
|
55
|
+
if (value === undefined || value === null) return;
|
|
56
|
+
if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${key}[${i}]`));
|
|
57
|
+
else if (typeof value === "object") for (const [k, v] of Object.entries(value)) walk(v, key ? `${key}[${k}]` : k);
|
|
58
|
+
else out.push([key, String(value)]);
|
|
59
|
+
};
|
|
60
|
+
walk(obj, "");
|
|
61
|
+
return new URLSearchParams(out);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function client(deps = {}, env = process.env) {
|
|
65
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
66
|
+
const key = env.STRIPE_API_KEY;
|
|
67
|
+
if (!key) throw new Error("Stripe is not set up. Missing: STRIPE_API_KEY (use a test key, sk_test_..., from the Stripe dashboard).");
|
|
68
|
+
const live = /^(sk|rk)_live_/.test(key);
|
|
69
|
+
if (live && env.STRIPE_ALLOW_LIVE !== "true") {
|
|
70
|
+
throw new Error("STRIPE_API_KEY is a LIVE key, which is blocked on purpose. Use a test key (sk_test_...). To really work with live money, set STRIPE_ALLOW_LIVE=true.");
|
|
71
|
+
}
|
|
72
|
+
const scrub = (text) => String(text).split(key).join("[REDACTED]");
|
|
73
|
+
const mode = live ? "live" : "test";
|
|
74
|
+
|
|
75
|
+
const call = async (method, path, params) => {
|
|
76
|
+
const form = params ? encode(params) : undefined;
|
|
77
|
+
const url = `${API}${path}${method === "GET" && form && [...form].length ? `?${form}` : ""}`;
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetchFn(url, {
|
|
81
|
+
method,
|
|
82
|
+
headers: {
|
|
83
|
+
authorization: `Bearer ${key}`,
|
|
84
|
+
...(method === "POST" ? { "content-type": "application/x-www-form-urlencoded", "idempotency-key": crypto.randomUUID() } : {}),
|
|
85
|
+
},
|
|
86
|
+
body: method === "POST" ? (form ?? new URLSearchParams()).toString() : undefined,
|
|
87
|
+
signal: AbortSignal.timeout(25_000),
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw new Error(`Could not reach Stripe: ${scrub(err.message)}`);
|
|
91
|
+
}
|
|
92
|
+
const data = await res.json().catch(() => ({}));
|
|
93
|
+
if (res.ok) return data;
|
|
94
|
+
const e = data.error ?? {};
|
|
95
|
+
if (res.status === 401) throw new Error("Stripe rejected the API key. Check STRIPE_API_KEY.");
|
|
96
|
+
if (res.status === 403) throw new Error(`Stripe refused: ${scrub(e.message || "permission denied")}. A restricted key may be missing a permission for this action.`);
|
|
97
|
+
if (res.status === 429) throw new Error("Stripe rate limit hit. Wait a moment and try again.");
|
|
98
|
+
throw new Error(`Stripe error: ${scrub(e.message || `HTTP ${res.status}`)}`);
|
|
99
|
+
};
|
|
100
|
+
call.mode = mode;
|
|
101
|
+
return call;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const iso = (sec) => (sec ? new Date(sec * 1000).toISOString() : null);
|
|
105
|
+
const cleanId = (id, prefix, label) => {
|
|
106
|
+
const s = String(id ?? "").trim();
|
|
107
|
+
if (!new RegExp(`^${prefix}_[A-Za-z0-9]+$`).test(s)) throw new Error(`${label} must be a Stripe id starting with ${prefix}_.`);
|
|
108
|
+
return s;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function invoiceView(i) {
|
|
112
|
+
const cur = i.currency;
|
|
113
|
+
return {
|
|
114
|
+
id: i.id,
|
|
115
|
+
number: i.number ?? null,
|
|
116
|
+
status: i.status,
|
|
117
|
+
customer: i.customer_email || i.customer_name || i.customer,
|
|
118
|
+
customer_id: typeof i.customer === "string" ? i.customer : i.customer?.id,
|
|
119
|
+
total: toMajor(i.total, cur),
|
|
120
|
+
amount_due: toMajor(i.amount_due, cur),
|
|
121
|
+
amount_paid: toMajor(i.amount_paid, cur),
|
|
122
|
+
currency: cur?.toUpperCase(),
|
|
123
|
+
due_date: iso(i.due_date)?.slice(0, 10) ?? null,
|
|
124
|
+
created: iso(i.created),
|
|
125
|
+
hosted_invoice_url: i.hosted_invoice_url ?? null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------- tools
|
|
130
|
+
|
|
131
|
+
export async function getAccount(_input = {}, deps = {}) {
|
|
132
|
+
const call = client(deps);
|
|
133
|
+
const b = await call("GET", "/balance");
|
|
134
|
+
const side = (list) => (list || []).map((x) => ({ currency: x.currency.toUpperCase(), amount: toMajor(x.amount, x.currency) }));
|
|
135
|
+
return { mode: call.mode, available: side(b.available), pending: side(b.pending) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function listCustomers({ email, query, limit = 10 } = {}, deps = {}) {
|
|
139
|
+
const call = client(deps);
|
|
140
|
+
const max = clamp(Math.trunc(Number(limit)) || 10, 1, 100);
|
|
141
|
+
let data;
|
|
142
|
+
if (query && String(query).trim()) {
|
|
143
|
+
const q = String(query).trim().replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
144
|
+
data = await call("GET", "/customers/search", { query: `name~"${q}"`, limit: max });
|
|
145
|
+
} else {
|
|
146
|
+
if (email && !EMAIL.test(String(email).trim())) throw new Error("email is not a valid email address.");
|
|
147
|
+
data = await call("GET", "/customers", { ...(email ? { email: String(email).trim().toLowerCase() } : {}), limit: max });
|
|
148
|
+
}
|
|
149
|
+
const customers = (data.data || []).map((c) => ({ id: c.id, name: c.name ?? null, email: c.email ?? null, created: iso(c.created), ...(c.description ? { description: String(c.description).slice(0, 200) } : {}) }));
|
|
150
|
+
return { mode: call.mode, count: customers.length, customers, notice: UNTRUSTED_NOTICE };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function createCustomer({ email, name, description } = {}, deps = {}) {
|
|
154
|
+
if (!email && !name) throw new Error("Give at least an email or a name.");
|
|
155
|
+
if (email && !EMAIL.test(String(email).trim())) throw new Error("email is not a valid email address.");
|
|
156
|
+
const call = client(deps);
|
|
157
|
+
const c = await call("POST", "/customers", {
|
|
158
|
+
...(email ? { email: String(email).trim().toLowerCase() } : {}),
|
|
159
|
+
...(name ? { name: String(name).trim() } : {}),
|
|
160
|
+
...(description ? { description: String(description) } : {}),
|
|
161
|
+
});
|
|
162
|
+
return { created: true, mode: call.mode, customer: { id: c.id, name: c.name ?? null, email: c.email ?? null } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function listInvoices({ customer, status, limit = 10 } = {}, deps = {}) {
|
|
166
|
+
const call = client(deps);
|
|
167
|
+
if (status && !["draft", "open", "paid", "uncollectible", "void"].includes(status)) throw new Error("status must be draft, open, paid, uncollectible or void.");
|
|
168
|
+
const data = await call("GET", "/invoices", {
|
|
169
|
+
...(customer ? { customer: cleanId(customer, "cus", "customer") } : {}),
|
|
170
|
+
...(status ? { status } : {}),
|
|
171
|
+
limit: clamp(Math.trunc(Number(limit)) || 10, 1, 100),
|
|
172
|
+
});
|
|
173
|
+
const invoices = (data.data || []).map(invoiceView);
|
|
174
|
+
return { mode: call.mode, count: invoices.length, invoices, notice: UNTRUSTED_NOTICE };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function resolveCustomer(call, customer, name) {
|
|
178
|
+
const s = String(customer ?? "").trim();
|
|
179
|
+
if (/^cus_[A-Za-z0-9]+$/.test(s)) return { id: s, created: false };
|
|
180
|
+
if (!EMAIL.test(s)) throw new Error("customer must be a Stripe customer id (cus_...) or an email address.");
|
|
181
|
+
const found = await call("GET", "/customers", { email: s.toLowerCase(), limit: 1 });
|
|
182
|
+
if (found.data?.length) return { id: found.data[0].id, created: false };
|
|
183
|
+
const made = await call("POST", "/customers", { email: s.toLowerCase(), ...(name ? { name: String(name).trim() } : {}) });
|
|
184
|
+
return { id: made.id, created: true };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function createInvoice({ customer, customer_name, items, currency, days_until_due = 14, memo } = {}, deps = {}) {
|
|
188
|
+
if (!Array.isArray(items) || !items.length) throw new Error("items is required: a list like [{ description, amount, quantity }].");
|
|
189
|
+
if (items.length > 20) throw new Error("20 line items at most.");
|
|
190
|
+
const cur = checkCurrency(currency);
|
|
191
|
+
const lines = items.map((it, n) => {
|
|
192
|
+
const description = String(it?.description ?? "").trim();
|
|
193
|
+
if (!description) throw new Error(`Item ${n + 1} needs a description.`);
|
|
194
|
+
if (description.length > 250) throw new Error(`Item ${n + 1}: description is over 250 characters.`);
|
|
195
|
+
const qty = it.quantity === undefined ? 1 : Number(it.quantity);
|
|
196
|
+
if (!Number.isInteger(qty) || qty < 1 || qty > 10_000) throw new Error(`Item ${n + 1}: quantity must be a whole number from 1 to 10000.`);
|
|
197
|
+
return { description, quantity: qty, unit_amount: toMinor(it.amount, cur, `Item ${n + 1} amount`) };
|
|
198
|
+
});
|
|
199
|
+
const days = clamp(Math.trunc(Number(days_until_due)) || 14, 1, 90);
|
|
200
|
+
|
|
201
|
+
const call = client(deps);
|
|
202
|
+
const cust = await resolveCustomer(call, customer, customer_name);
|
|
203
|
+
const inv = await call("POST", "/invoices", {
|
|
204
|
+
customer: cust.id,
|
|
205
|
+
currency: cur,
|
|
206
|
+
collection_method: "send_invoice",
|
|
207
|
+
days_until_due: days,
|
|
208
|
+
auto_advance: false,
|
|
209
|
+
pending_invoice_items_behavior: "exclude",
|
|
210
|
+
...(memo ? { description: String(memo).slice(0, 1000) } : {}),
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
for (const line of lines) {
|
|
214
|
+
await call("POST", "/invoiceitems", { customer: cust.id, invoice: inv.id, currency: cur, ...line });
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
await call("DELETE", `/invoices/${inv.id}`).catch(() => {}); // do not leave a half-built draft behind
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
const full = await call("GET", `/invoices/${inv.id}`);
|
|
221
|
+
return {
|
|
222
|
+
created: true,
|
|
223
|
+
mode: call.mode,
|
|
224
|
+
customer_created: cust.created,
|
|
225
|
+
invoice: invoiceView(full),
|
|
226
|
+
next: "This is a draft: nothing was sent or charged. Use stripe_send_invoice (needs confirm: true) to email it to the customer.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function sendInvoice({ invoice, confirm } = {}, deps = {}) {
|
|
231
|
+
const id = cleanId(invoice, "in", "invoice");
|
|
232
|
+
const call = client(deps);
|
|
233
|
+
const current = await call("GET", `/invoices/${id}`);
|
|
234
|
+
if (current.status === "paid" || current.status === "void" || current.status === "uncollectible") {
|
|
235
|
+
throw new Error(`Invoice ${id} is ${current.status}; it cannot be sent.`);
|
|
236
|
+
}
|
|
237
|
+
if (!current.total) throw new Error(`Invoice ${id} has no amount; add line items first.`);
|
|
238
|
+
let working = current;
|
|
239
|
+
if (current.status === "draft") working = await call("POST", `/invoices/${id}/finalize`, { auto_advance: false });
|
|
240
|
+
const sent = await call("POST", `/invoices/${id}/send`);
|
|
241
|
+
return { sent: true, mode: call.mode, invoice: invoiceView({ ...working, ...sent }) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function createPaymentLink({ name, amount, currency, confirm } = {}, deps = {}) {
|
|
245
|
+
const title = String(name ?? "").trim();
|
|
246
|
+
if (!title) throw new Error("name is required (what the customer is paying for).");
|
|
247
|
+
if (title.length > 250) throw new Error("name is over 250 characters.");
|
|
248
|
+
const cur = checkCurrency(currency);
|
|
249
|
+
const minor = toMinor(amount, cur);
|
|
250
|
+
const call = client(deps);
|
|
251
|
+
const price = await call("POST", "/prices", { currency: cur, unit_amount: minor, product_data: { name: title } });
|
|
252
|
+
const link = await call("POST", "/payment_links", { line_items: [{ price: price.id, quantity: 1 }] });
|
|
253
|
+
return { created: true, mode: call.mode, url: link.url, id: link.id, name: title, amount: toMajor(minor, cur), currency: cur.toUpperCase() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function listPayments({ customer, limit = 10 } = {}, deps = {}) {
|
|
257
|
+
const call = client(deps);
|
|
258
|
+
const data = await call("GET", "/payment_intents", {
|
|
259
|
+
...(customer ? { customer: cleanId(customer, "cus", "customer") } : {}),
|
|
260
|
+
limit: clamp(Math.trunc(Number(limit)) || 10, 1, 100),
|
|
261
|
+
});
|
|
262
|
+
const payments = (data.data || []).map((p) => ({
|
|
263
|
+
id: p.id,
|
|
264
|
+
status: p.status,
|
|
265
|
+
amount: toMajor(p.amount, p.currency),
|
|
266
|
+
received: toMajor(p.amount_received, p.currency),
|
|
267
|
+
currency: p.currency?.toUpperCase(),
|
|
268
|
+
customer: typeof p.customer === "string" ? p.customer : p.customer?.id ?? null,
|
|
269
|
+
...(p.description ? { description: String(p.description).slice(0, 200) } : {}),
|
|
270
|
+
created: iso(p.created),
|
|
271
|
+
}));
|
|
272
|
+
return { mode: call.mode, count: payments.length, payments, notice: UNTRUSTED_NOTICE };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function refund({ payment, amount, reason, confirm } = {}, deps = {}) {
|
|
276
|
+
const id = cleanId(payment, "pi", "payment");
|
|
277
|
+
if (reason !== undefined && !["duplicate", "fraudulent", "requested_by_customer"].includes(reason)) {
|
|
278
|
+
throw new Error("reason must be duplicate, fraudulent or requested_by_customer.");
|
|
279
|
+
}
|
|
280
|
+
const call = client(deps);
|
|
281
|
+
const pi = await call("GET", `/payment_intents/${id}`);
|
|
282
|
+
const received = pi.amount_received ?? 0;
|
|
283
|
+
if (!received) throw new Error(`Payment ${id} has nothing to refund (status ${pi.status}).`);
|
|
284
|
+
let minor;
|
|
285
|
+
if (amount !== undefined && amount !== null && amount !== "") {
|
|
286
|
+
minor = toMinor(amount, pi.currency, "amount");
|
|
287
|
+
if (minor > received) throw new Error(`amount ${toMajor(minor, pi.currency)} is more than the ${toMajor(received, pi.currency)} ${pi.currency.toUpperCase()} received.`);
|
|
288
|
+
}
|
|
289
|
+
const r = await call("POST", "/refunds", { payment_intent: id, ...(minor ? { amount: minor } : {}), ...(reason ? { reason } : {}) });
|
|
290
|
+
return {
|
|
291
|
+
refunded: true,
|
|
292
|
+
mode: call.mode,
|
|
293
|
+
refund_id: r.id,
|
|
294
|
+
status: r.status,
|
|
295
|
+
amount: toMajor(r.amount, r.currency),
|
|
296
|
+
currency: r.currency?.toUpperCase(),
|
|
297
|
+
full_refund: !minor || minor === received,
|
|
298
|
+
};
|
|
299
|
+
}
|