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,133 @@
|
|
|
1
|
+
// Discord tools over the REST API (v10). No new dependencies; a bot token is all that is needed.
|
|
2
|
+
//
|
|
3
|
+
// discord_get_me check the bot token, show the bot
|
|
4
|
+
// discord_list_guilds servers the bot is in
|
|
5
|
+
// discord_list_channels channels of one server, grouped by category
|
|
6
|
+
// discord_read_messages recent messages in a channel
|
|
7
|
+
// discord_send_message post a message (HIGH risk, needs confirm: true)
|
|
8
|
+
//
|
|
9
|
+
// Credentials: DISCORD_BOT_TOKEN (Discord Developer Portal > your app > Bot > Reset Token).
|
|
10
|
+
// Invite the bot with the permissions View Channels, Read Message History, Send Messages.
|
|
11
|
+
// To read what OTHER people wrote, turn on "Message Content Intent" for the bot in the portal;
|
|
12
|
+
// without it Discord returns empty text for most messages.
|
|
13
|
+
import { UNTRUSTED_NOTICE } from "./webOps.js";
|
|
14
|
+
|
|
15
|
+
const API = "https://discord.com/api/v10";
|
|
16
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
|
|
17
|
+
const SNOWFLAKE = /^\d{15,25}$/;
|
|
18
|
+
const MAX_TEXT = 2000;
|
|
19
|
+
const CHANNEL_KINDS = { 0: "text", 5: "announcement", 15: "forum" };
|
|
20
|
+
|
|
21
|
+
function snowflake(value, label) {
|
|
22
|
+
const s = String(value ?? "").trim();
|
|
23
|
+
if (!SNOWFLAKE.test(s)) throw new Error(`${label} must be a Discord id (a long number). In Discord, turn on Developer Mode and right-click > Copy ID, or use discord_list_guilds / discord_list_channels.`);
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function call(method, path, { query, body } = {}, deps = {}) {
|
|
28
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
29
|
+
const token = process.env.DISCORD_BOT_TOKEN;
|
|
30
|
+
if (!token) throw new Error("Discord is not set up. Missing: DISCORD_BOT_TOKEN (create a bot in the Discord Developer Portal and add its token to .env).");
|
|
31
|
+
const scrub = (text) => String(text).split(token).join("[REDACTED]");
|
|
32
|
+
const pairs = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== "");
|
|
33
|
+
const qs = pairs.length ? `?${new URLSearchParams(pairs.map(([k, v]) => [k, String(v)]))}` : "";
|
|
34
|
+
|
|
35
|
+
let res;
|
|
36
|
+
try {
|
|
37
|
+
res = await fetchFn(`${API}${path}${qs}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
authorization: `Bot ${token}`,
|
|
41
|
+
"user-agent": "DiscordBot (https://github.com/KNIHAL/causly-server, 1.0)",
|
|
42
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
43
|
+
},
|
|
44
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
45
|
+
signal: AbortSignal.timeout(20_000),
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Could not reach Discord: ${scrub(err.message)}`);
|
|
49
|
+
}
|
|
50
|
+
const data = await res.json().catch(() => ({}));
|
|
51
|
+
if (res.ok) return data;
|
|
52
|
+
|
|
53
|
+
const why = scrub(data.message || `HTTP ${res.status}`);
|
|
54
|
+
if (res.status === 401) throw new Error("Discord rejected the bot token. Check DISCORD_BOT_TOKEN.");
|
|
55
|
+
if (res.status === 403) throw new Error(`Discord refused: ${why}. The bot is missing access or a permission there: check its role and the channel's permissions (View Channel, Read Message History, Send Messages).`);
|
|
56
|
+
if (res.status === 404) throw new Error(`Discord could not find that: ${why}. Check the id, and that the bot is in that server.`);
|
|
57
|
+
if (res.status === 429) throw new Error(`Discord rate limit hit${data.retry_after ? `; retry in ${Math.ceil(data.retry_after)}s` : ""}.`);
|
|
58
|
+
throw new Error(`Discord error: ${why}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- tools
|
|
62
|
+
|
|
63
|
+
export async function getMe(_input = {}, deps = {}) {
|
|
64
|
+
const me = await call("GET", "/users/@me", {}, deps);
|
|
65
|
+
const guilds = await call("GET", "/users/@me/guilds", { query: { limit: 200 } }, deps);
|
|
66
|
+
return { id: me.id, username: me.username, servers: guilds.length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listGuilds(_input = {}, deps = {}) {
|
|
70
|
+
const data = await call("GET", "/users/@me/guilds", { query: { limit: 200 } }, deps);
|
|
71
|
+
return { count: data.length, servers: data.map((g) => ({ id: g.id, name: g.name, ...(g.owner ? { owner: true } : {}) })) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function listChannels({ guild_id } = {}, deps = {}) {
|
|
75
|
+
const id = snowflake(guild_id, "guild_id");
|
|
76
|
+
const data = await call("GET", `/guilds/${id}/channels`, {}, deps);
|
|
77
|
+
const categories = new Map(data.filter((c) => c.type === 4).map((c) => [c.id, c.name]));
|
|
78
|
+
const channels = data
|
|
79
|
+
.filter((c) => c.type in CHANNEL_KINDS)
|
|
80
|
+
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
81
|
+
.map((c) => ({
|
|
82
|
+
id: c.id,
|
|
83
|
+
name: c.name,
|
|
84
|
+
type: CHANNEL_KINDS[c.type],
|
|
85
|
+
...(c.parent_id && categories.has(c.parent_id) ? { category: categories.get(c.parent_id) } : {}),
|
|
86
|
+
...(c.topic ? { topic: String(c.topic).slice(0, 200) } : {}),
|
|
87
|
+
}));
|
|
88
|
+
return { guild_id: id, count: channels.length, channels, notice: UNTRUSTED_NOTICE };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function readMessages({ channel_id, limit = 20, before, after } = {}, deps = {}) {
|
|
92
|
+
const id = snowflake(channel_id, "channel_id");
|
|
93
|
+
const query = {
|
|
94
|
+
limit: clamp(Math.trunc(Number(limit)) || 20, 1, 100),
|
|
95
|
+
...(before ? { before: snowflake(before, "before") } : {}),
|
|
96
|
+
...(after ? { after: snowflake(after, "after") } : {}),
|
|
97
|
+
};
|
|
98
|
+
const raw = await call("GET", `/channels/${id}/messages`, { query }, deps);
|
|
99
|
+
const messages = [...raw].reverse().map((m) => ({
|
|
100
|
+
id: m.id,
|
|
101
|
+
author: m.author?.username ?? "unknown",
|
|
102
|
+
author_id: m.author?.id,
|
|
103
|
+
...(m.author?.bot ? { bot: true } : {}),
|
|
104
|
+
time: m.timestamp,
|
|
105
|
+
text: m.content ?? "",
|
|
106
|
+
...(m.message_reference?.message_id ? { reply_to: m.message_reference.message_id } : {}),
|
|
107
|
+
...(m.attachments?.length ? { attachments: m.attachments.length } : {}),
|
|
108
|
+
...(m.embeds?.length ? { embeds: m.embeds.length } : {}),
|
|
109
|
+
}));
|
|
110
|
+
const blank = messages.length > 0 && messages.every((m) => !m.text && !m.attachments && !m.embeds);
|
|
111
|
+
return {
|
|
112
|
+
channel_id: id,
|
|
113
|
+
count: messages.length,
|
|
114
|
+
messages,
|
|
115
|
+
...(blank ? { note: "Every message came back with empty text. Turn on 'Message Content Intent' for the bot in the Discord Developer Portal (Bot page), then try again." } : {}),
|
|
116
|
+
notice: UNTRUSTED_NOTICE,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** HIGH risk: posts as the bot where other people read it. The wrapper requires confirm: true. */
|
|
121
|
+
export async function sendMessage({ channel_id, text, reply_to_message_id, confirm } = {}, deps = {}) {
|
|
122
|
+
const id = snowflake(channel_id, "channel_id");
|
|
123
|
+
const content = String(text ?? "");
|
|
124
|
+
if (!content.trim()) throw new Error("text is required.");
|
|
125
|
+
if (content.length > MAX_TEXT) throw new Error(`text is ${content.length} characters; Discord allows ${MAX_TEXT}. Shorten it or send it in parts.`);
|
|
126
|
+
const body = {
|
|
127
|
+
content,
|
|
128
|
+
allowed_mentions: { parse: [] }, // never ping @everyone, @here, roles or people by accident
|
|
129
|
+
...(reply_to_message_id ? { message_reference: { message_id: snowflake(reply_to_message_id, "reply_to_message_id"), fail_if_not_exists: false } } : {}),
|
|
130
|
+
};
|
|
131
|
+
const m = await call("POST", `/channels/${id}/messages`, { body }, deps);
|
|
132
|
+
return { sent: true, id: m.id, channel_id: m.channel_id, time: m.timestamp };
|
|
133
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const { execFile } = await import("child_process");
|
|
2
|
+
const { promisify } = await import("util");
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
|
|
5
|
+
/** Converts a Windows path (D:\foo\bar) to its WSL equivalent (/mnt/d/foo/bar). Leaves non-Windows-style paths untouched. */
|
|
6
|
+
function toWslPath(p) {
|
|
7
|
+
if (typeof p !== "string" || !/^[a-zA-Z]:\\/.test(p)) return p;
|
|
8
|
+
const drive = p[0].toLowerCase();
|
|
9
|
+
const rest = p.slice(2).replace(/\\/g, "/");
|
|
10
|
+
return `/mnt/${drive}${rest}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Applies toWslPath to a "HOST:CONTAINER" volume string's host side only. */
|
|
14
|
+
function toWslVolume(v) {
|
|
15
|
+
if (typeof v !== "string") return v;
|
|
16
|
+
const idx = v.indexOf(":", 2); // skip past "D:" drive-letter colon
|
|
17
|
+
if (v.length > 1 && v[1] === ":" ) {
|
|
18
|
+
const rest = v.slice(2);
|
|
19
|
+
const sep = rest.indexOf(":");
|
|
20
|
+
if (sep === -1) return v;
|
|
21
|
+
const hostPart = v.slice(0, 2) + rest.slice(0, sep);
|
|
22
|
+
const containerPart = rest.slice(sep);
|
|
23
|
+
return toWslPath(hostPart) + containerPart;
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cross-platform Docker runner. Tries `docker` directly first — this covers
|
|
30
|
+
* macOS, Linux, and Windows with Docker Desktop (which puts docker.exe on PATH).
|
|
31
|
+
* If that fails with a "command not found" style error AND we're on Windows,
|
|
32
|
+
* falls back to `wsl docker ...` — this covers Windows setups where Docker
|
|
33
|
+
* only exists inside WSL (no Docker Desktop installed). When falling back,
|
|
34
|
+
* pass `wslArgs`/`wslCwd` (WSL-path versions) if the direct args contain
|
|
35
|
+
* Windows-style paths that WSL's docker can't resolve.
|
|
36
|
+
*/
|
|
37
|
+
async function runDocker(args, { timeout_ms = 60000, cwd, wslArgs, wslCwd } = {}) {
|
|
38
|
+
const attempt = async (cmd, cmdArgs, cmdCwd) => {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout, stderr } = await execFileAsync(cmd, cmdArgs, {
|
|
41
|
+
cwd: cmdCwd,
|
|
42
|
+
timeout: timeout_ms,
|
|
43
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
44
|
+
});
|
|
45
|
+
return { stdout, stderr, exit_code: 0 };
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return {
|
|
48
|
+
stdout: err.stdout || "",
|
|
49
|
+
stderr: err.stderr || err.message,
|
|
50
|
+
exit_code: err.code ?? 1,
|
|
51
|
+
notFound: err.code === "ENOENT" || /is not recognized|command not found/i.test(err.message || ""),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const direct = await attempt("docker", args, cwd);
|
|
57
|
+
if (direct.exit_code === 0 || !direct.notFound) return direct;
|
|
58
|
+
|
|
59
|
+
if (process.platform === "win32") {
|
|
60
|
+
return attempt("wsl", ["docker", ...(wslArgs || args)], wslCwd || cwd);
|
|
61
|
+
}
|
|
62
|
+
return direct;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Get Docker client/server version info — connectivity check. */
|
|
66
|
+
export async function dockerVersion() {
|
|
67
|
+
return runDocker(["version"]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List containers. Set all=true to include stopped ones (defaults to running only). */
|
|
71
|
+
export async function dockerPs({ all = false }) {
|
|
72
|
+
const args = ["ps", "--format", "{{json .}}"];
|
|
73
|
+
if (all) args.push("-a");
|
|
74
|
+
const result = await runDocker(args);
|
|
75
|
+
if (result.exit_code !== 0) return result;
|
|
76
|
+
const containers = result.stdout
|
|
77
|
+
.trim()
|
|
78
|
+
.split("\n")
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((line) => JSON.parse(line));
|
|
81
|
+
return { containers };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** List images. */
|
|
85
|
+
export async function dockerImages() {
|
|
86
|
+
const result = await runDocker(["images", "--format", "{{json .}}"]);
|
|
87
|
+
if (result.exit_code !== 0) return result;
|
|
88
|
+
const images = result.stdout
|
|
89
|
+
.trim()
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.map((line) => JSON.parse(line));
|
|
93
|
+
return { images };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build an image from a Dockerfile. HIGH risk — requires confirm: true. */
|
|
97
|
+
export async function dockerBuild({ context_dir, tag, dockerfile, build_args, confirm }) {
|
|
98
|
+
const buildArgsFlags = [];
|
|
99
|
+
if (build_args) {
|
|
100
|
+
for (const [k, v] of Object.entries(build_args)) {
|
|
101
|
+
buildArgsFlags.push("--build-arg", `${k}=${v}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const args = ["build", "-t", tag, ...(dockerfile ? ["-f", dockerfile] : []), ...buildArgsFlags, context_dir];
|
|
106
|
+
const wslArgs = [
|
|
107
|
+
"build",
|
|
108
|
+
"-t",
|
|
109
|
+
tag,
|
|
110
|
+
...(dockerfile ? ["-f", toWslPath(dockerfile)] : []),
|
|
111
|
+
...buildArgsFlags,
|
|
112
|
+
toWslPath(context_dir),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
return runDocker(args, { timeout_ms: 600000, wslArgs });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Run a new container from an image. HIGH risk — requires confirm: true. */
|
|
119
|
+
export async function dockerRun({ image, name, ports, env, volumes, detach = true, command, confirm }) {
|
|
120
|
+
const base = [];
|
|
121
|
+
if (detach) base.push("-d");
|
|
122
|
+
if (name) base.push("--name", name);
|
|
123
|
+
if (ports) for (const p of ports) base.push("-p", p);
|
|
124
|
+
if (env) for (const [k, v] of Object.entries(env)) base.push("-e", `${k}=${v}`);
|
|
125
|
+
|
|
126
|
+
const args = ["run", ...base];
|
|
127
|
+
const wslArgs = ["run", ...base];
|
|
128
|
+
if (volumes) {
|
|
129
|
+
for (const v of volumes) {
|
|
130
|
+
args.push("-v", v);
|
|
131
|
+
wslArgs.push("-v", toWslVolume(v));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
args.push(image);
|
|
135
|
+
wslArgs.push(image);
|
|
136
|
+
if (command) {
|
|
137
|
+
const cmdParts = command.split(" ");
|
|
138
|
+
args.push(...cmdParts);
|
|
139
|
+
wslArgs.push(...cmdParts);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return runDocker(args, { timeout_ms: 120000, wslArgs });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stop a running container. HIGH risk — requires confirm: true. */
|
|
146
|
+
export async function dockerStop({ container, confirm }) {
|
|
147
|
+
return runDocker(["stop", container]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Start a stopped container. */
|
|
151
|
+
export async function dockerStart({ container }) {
|
|
152
|
+
return runDocker(["start", container]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Restart a container. HIGH risk — requires confirm: true. */
|
|
156
|
+
export async function dockerRestart({ container, confirm }) {
|
|
157
|
+
return runDocker(["restart", container]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Remove a container. DESTRUCTIVE — requires confirm: true. */
|
|
161
|
+
export async function dockerRemove({ container, force = false, confirm }) {
|
|
162
|
+
const args = ["rm"];
|
|
163
|
+
if (force) args.push("-f");
|
|
164
|
+
args.push(container);
|
|
165
|
+
return runDocker(args);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Remove an image. DESTRUCTIVE — requires confirm: true. */
|
|
169
|
+
export async function dockerRemoveImage({ image, force = false, confirm }) {
|
|
170
|
+
const args = ["rmi"];
|
|
171
|
+
if (force) args.push("-f");
|
|
172
|
+
args.push(image);
|
|
173
|
+
return runDocker(args);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Get logs from a container. */
|
|
177
|
+
export async function dockerLogs({ container, tail = 100, since }) {
|
|
178
|
+
const args = ["logs", "--tail", String(tail)];
|
|
179
|
+
if (since) args.push("--since", since);
|
|
180
|
+
args.push(container);
|
|
181
|
+
return runDocker(args);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Inspect a container or image — full JSON metadata. */
|
|
185
|
+
export async function dockerInspect({ target }) {
|
|
186
|
+
const result = await runDocker(["inspect", target]);
|
|
187
|
+
if (result.exit_code !== 0) return result;
|
|
188
|
+
try {
|
|
189
|
+
return { data: JSON.parse(result.stdout) };
|
|
190
|
+
} catch {
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Execute a command inside a running container. */
|
|
196
|
+
export async function dockerExec({ container, command }) {
|
|
197
|
+
const args = ["exec", container, ...command.split(" ")];
|
|
198
|
+
return runDocker(args);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Show live resource usage stats (CPU, memory) for running containers — one-shot snapshot, not streaming. */
|
|
202
|
+
export async function dockerStats() {
|
|
203
|
+
const result = await runDocker(["stats", "--no-stream", "--format", "{{json .}}"]);
|
|
204
|
+
if (result.exit_code !== 0) return result;
|
|
205
|
+
const stats = result.stdout
|
|
206
|
+
.trim()
|
|
207
|
+
.split("\n")
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.map((line) => JSON.parse(line));
|
|
210
|
+
return { stats };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Push an image to a registry. HIGH risk — requires confirm: true. */
|
|
214
|
+
export async function dockerPush({ image, confirm }) {
|
|
215
|
+
return runDocker(["push", image], { timeout_ms: 300000 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Pull an image from a registry. */
|
|
219
|
+
export async function dockerPull({ image }) {
|
|
220
|
+
return runDocker(["pull", image], { timeout_ms: 300000 });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Run docker compose up for a project directory. HIGH risk — requires confirm: true. */
|
|
224
|
+
export async function dockerComposeUp({ project_dir, detach = true, confirm }) {
|
|
225
|
+
const args = ["compose", "up"];
|
|
226
|
+
if (detach) args.push("-d");
|
|
227
|
+
const direct = await runDocker(args, { cwd: project_dir, timeout_ms: 300000 });
|
|
228
|
+
if (direct.exit_code === 0 || !direct.notFound || process.platform !== "win32") return direct;
|
|
229
|
+
|
|
230
|
+
const wslDir = toWslPath(project_dir);
|
|
231
|
+
const composeCmd = `cd '${wslDir}' && docker compose up${detach ? " -d" : ""}`;
|
|
232
|
+
try {
|
|
233
|
+
const { stdout, stderr } = await execFileAsync("wsl", ["bash", "-lc", composeCmd], {
|
|
234
|
+
timeout: 300000,
|
|
235
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
236
|
+
});
|
|
237
|
+
return { stdout, stderr, exit_code: 0 };
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return { stdout: err.stdout || "", stderr: err.stderr || err.message, exit_code: err.code ?? 1 };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Run docker compose down for a project directory. HIGH risk — requires confirm: true. */
|
|
244
|
+
export async function dockerComposeDown({ project_dir, confirm }) {
|
|
245
|
+
const direct = await runDocker(["compose", "down"], { cwd: project_dir, timeout_ms: 120000 });
|
|
246
|
+
if (direct.exit_code === 0 || !direct.notFound || process.platform !== "win32") return direct;
|
|
247
|
+
|
|
248
|
+
const wslDir = toWslPath(project_dir);
|
|
249
|
+
const composeCmd = `cd '${wslDir}' && docker compose down`;
|
|
250
|
+
try {
|
|
251
|
+
const { stdout, stderr } = await execFileAsync("wsl", ["bash", "-lc", composeCmd], {
|
|
252
|
+
timeout: 120000,
|
|
253
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
254
|
+
});
|
|
255
|
+
return { stdout, stderr, exit_code: 0 };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return { stdout: err.stdout || "", stderr: err.stderr || err.message, exit_code: err.code ?? 1 };
|
|
258
|
+
}
|
|
259
|
+
}
|
package/tools/envFile.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Reading and editing the settings (.env) file without disturbing anything else in it.
|
|
2
|
+
// Used by `causly-server keys` and `causly-server setup`. Values are never logged here.
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
const NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
8
|
+
export const isValidName = (n) => NAME.test(String(n));
|
|
9
|
+
|
|
10
|
+
const stripQuotes = (v) => ((v.startsWith('"') && v.endsWith('"') && v.length > 1) || (v.startsWith("'") && v.endsWith("'") && v.length > 1) ? v.slice(1, -1) : v);
|
|
11
|
+
|
|
12
|
+
/** { NAME: value } from the active (uncommented) lines of a .env file. Missing file gives {}. */
|
|
13
|
+
export function readEnvValues(file) {
|
|
14
|
+
if (!fs.existsSync(file)) return {};
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
17
|
+
const line = raw.trim();
|
|
18
|
+
if (!line || line.startsWith("#")) continue;
|
|
19
|
+
const eq = line.indexOf("=");
|
|
20
|
+
if (eq === -1) continue;
|
|
21
|
+
const key = line.slice(0, eq).trim();
|
|
22
|
+
if (key) out[key] = stripQuotes(line.slice(eq + 1).trim());
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Trim and validate a pasted value. Throws a plain message if it cannot be stored safely. */
|
|
28
|
+
export function checkValue(value) {
|
|
29
|
+
const v = String(value ?? "").trim();
|
|
30
|
+
if (!v) throw new Error("The value is empty.");
|
|
31
|
+
if (/[\r\n]/.test(v)) throw new Error("The value has a line break in it. Paste just the key.");
|
|
32
|
+
if (v.length > 4096) throw new Error("The value is too long (4096 characters at most).");
|
|
33
|
+
if (/^["']|["']$/.test(v)) throw new Error("The value starts or ends with a quote mark. Paste it without quotes.");
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeAtomic(file, text) {
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
39
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
40
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
41
|
+
fs.renameSync(tmp, file);
|
|
42
|
+
if (os.platform() !== "win32") fs.chmodSync(file, 0o600); // keys are for the owner only
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
|
|
47
|
+
/** Set NAME=value: replace the active line, else fill in a "# NAME=" placeholder, else append. */
|
|
48
|
+
export function setEnvValue(file, name, value) {
|
|
49
|
+
if (!isValidName(name)) throw new Error(`"${name}" is not a valid setting name (capital letters, digits and underscores).`);
|
|
50
|
+
const v = checkValue(value);
|
|
51
|
+
const existed = fs.existsSync(file);
|
|
52
|
+
const text = existed ? fs.readFileSync(file, "utf8") : "";
|
|
53
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
54
|
+
const lines = text === "" ? [] : text.split(/\r?\n/);
|
|
55
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // the split leaves one empty item after a final newline
|
|
56
|
+
const active = new RegExp(`^\\s*${escapeRe(name)}\\s*=`);
|
|
57
|
+
const placeholder = new RegExp(`^\\s*#\\s*${escapeRe(name)}\\s*=`);
|
|
58
|
+
let at = lines.findIndex((l) => active.test(l));
|
|
59
|
+
const replaced = at !== -1;
|
|
60
|
+
if (at === -1) at = lines.findIndex((l) => placeholder.test(l));
|
|
61
|
+
const line = `${name}=${v}`;
|
|
62
|
+
if (at !== -1) lines[at] = line;
|
|
63
|
+
else {
|
|
64
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
65
|
+
lines.push(line);
|
|
66
|
+
}
|
|
67
|
+
writeAtomic(file, `${lines.join(eol)}${eol}`);
|
|
68
|
+
return { created: !existed, replaced };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove NAME from the file (every active line for it). Returns whether anything was removed. */
|
|
72
|
+
export function unsetEnvValue(file, name) {
|
|
73
|
+
if (!fs.existsSync(file)) return false;
|
|
74
|
+
const text = fs.readFileSync(file, "utf8");
|
|
75
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
76
|
+
const active = new RegExp(`^\\s*${escapeRe(name)}\\s*=`);
|
|
77
|
+
const lines = text.split(/\r?\n/);
|
|
78
|
+
const kept = lines.filter((l) => !active.test(l));
|
|
79
|
+
if (kept.length === lines.length) return false;
|
|
80
|
+
writeAtomic(file, kept.join(eol));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "set (…ab12)" for a secret long enough to hint at, "set" otherwise. Never the whole value. */
|
|
85
|
+
export function describeSecret(value) {
|
|
86
|
+
const v = String(value ?? "");
|
|
87
|
+
if (!v) return "missing";
|
|
88
|
+
return v.length >= 12 ? `set (…${v.slice(-4)})` : "set";
|
|
89
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { envFiles } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal, dependency-free .env loader.
|
|
6
|
+
* Written by hand instead of using the `dotenv` package because dotenv
|
|
7
|
+
* prints a banner to stdout on load, which corrupts the MCP stdio
|
|
8
|
+
* JSON-RPC stream (stdout must contain ONLY protocol messages).
|
|
9
|
+
*
|
|
10
|
+
* Files are read in order: CAUSLY_ENV_FILE, ~/.causly/.env, then the .env next to the
|
|
11
|
+
* code (a git checkout). A variable that is already set is never overwritten, so the
|
|
12
|
+
* client's own "env" block and earlier files win.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let launchKeys = null; // variables the process started with: files never override these
|
|
16
|
+
let fromFiles = new Set(); // variables we set from files, so a reload can update or drop them
|
|
17
|
+
let seen = new Map(); // file -> "mtimeMs:size" at last load
|
|
18
|
+
|
|
19
|
+
function applyFiles() {
|
|
20
|
+
for (const file of envFiles()) {
|
|
21
|
+
if (!fs.existsSync(file)) continue;
|
|
22
|
+
let content;
|
|
23
|
+
try {
|
|
24
|
+
content = fs.readFileSync(file, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const rawLine of content.split("\n")) {
|
|
29
|
+
const line = rawLine.trim();
|
|
30
|
+
if (!line || line.startsWith("#")) continue;
|
|
31
|
+
|
|
32
|
+
const eqIndex = line.indexOf("=");
|
|
33
|
+
if (eqIndex === -1) continue;
|
|
34
|
+
|
|
35
|
+
const key = line.slice(0, eqIndex).trim();
|
|
36
|
+
let value = line.slice(eqIndex + 1).trim();
|
|
37
|
+
|
|
38
|
+
// Strip surrounding quotes if present.
|
|
39
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
40
|
+
value = value.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (key && !(key in process.env)) {
|
|
44
|
+
process.env[key] = value;
|
|
45
|
+
fromFiles.add(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadEnv() {
|
|
52
|
+
if (launchKeys === null) launchKeys = new Set(Object.keys(process.env));
|
|
53
|
+
applyFiles();
|
|
54
|
+
snapshot();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stamp(file) {
|
|
58
|
+
try {
|
|
59
|
+
const s = fs.statSync(file);
|
|
60
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function snapshot() {
|
|
67
|
+
seen = new Map(envFiles().map((f) => [f, stamp(f)]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pick up changes made to the settings files while the server is running (for example by
|
|
72
|
+
* `causly-server setup`), so a new key works on the next tool call without a restart.
|
|
73
|
+
* Variables the process started with keep winning. Cheap: a stat per file per call.
|
|
74
|
+
*/
|
|
75
|
+
export function reloadEnvIfChanged() {
|
|
76
|
+
if (launchKeys === null) return false;
|
|
77
|
+
const files = envFiles();
|
|
78
|
+
const changed = files.length !== seen.size || files.some((f) => seen.get(f) !== stamp(f));
|
|
79
|
+
if (!changed) return false;
|
|
80
|
+
for (const key of fromFiles) if (!launchKeys.has(key)) delete process.env[key];
|
|
81
|
+
fromFiles = new Set();
|
|
82
|
+
applyFiles();
|
|
83
|
+
snapshot();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
package/tools/fileOps.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { isPathDenied } from "./security.js";
|
|
4
|
+
|
|
5
|
+
function assertPathAllowed(targetPath) {
|
|
6
|
+
const check = isPathDenied(targetPath);
|
|
7
|
+
if (check.denied) throw new Error(check.reason);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* mkdir(recursive: true) that tolerates Windows quirks where the target
|
|
12
|
+
* directory is a drive root (e.g. "D:\") — Node throws EPERM there even
|
|
13
|
+
* though the directory already exists.
|
|
14
|
+
*/
|
|
15
|
+
async function safeMkdir(dirPath) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code !== "EPERM" && err.code !== "EEXIST") throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read a single file's full text content. */
|
|
24
|
+
export async function readFile({ path: filePath, encoding = "utf8" }) {
|
|
25
|
+
const content = await fs.readFile(filePath, encoding);
|
|
26
|
+
return { path: filePath, content };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read multiple files in one call — avoids repeated round trips. */
|
|
30
|
+
export async function readMultipleFiles({ paths, encoding = "utf8" }) {
|
|
31
|
+
const results = await Promise.all(
|
|
32
|
+
paths.map(async (p) => {
|
|
33
|
+
try {
|
|
34
|
+
const content = await fs.readFile(p, encoding);
|
|
35
|
+
return { path: p, content, success: true };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { path: p, error: err.message, success: false };
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
return { results };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create a brand-new file. Fails if the file already exists (safety). */
|
|
45
|
+
export async function createFile({ path: filePath, content = "" }) {
|
|
46
|
+
assertPathAllowed(filePath);
|
|
47
|
+
await safeMkdir(path.dirname(filePath));
|
|
48
|
+
await fs.writeFile(filePath, content, { flag: "wx" });
|
|
49
|
+
return { path: filePath, created: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write/overwrite a file completely. Creates parent dirs if needed. */
|
|
53
|
+
export async function writeFile({ path: filePath, content }) {
|
|
54
|
+
assertPathAllowed(filePath);
|
|
55
|
+
await safeMkdir(path.dirname(filePath));
|
|
56
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
57
|
+
return { path: filePath, written: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Edit a file via exact string replacement (find & replace) without
|
|
62
|
+
* rewriting the whole file manually. `old_str` must be unique unless
|
|
63
|
+
* replace_all is true.
|
|
64
|
+
*/
|
|
65
|
+
export async function editFile({ path: filePath, old_str, new_str, replace_all = false }) {
|
|
66
|
+
assertPathAllowed(filePath);
|
|
67
|
+
const original = await fs.readFile(filePath, "utf8");
|
|
68
|
+
|
|
69
|
+
if (!original.includes(old_str)) {
|
|
70
|
+
throw new Error("old_str not found in file — no changes made.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let updated;
|
|
74
|
+
if (replace_all) {
|
|
75
|
+
updated = original.split(old_str).join(new_str);
|
|
76
|
+
} else {
|
|
77
|
+
const firstIndex = original.indexOf(old_str);
|
|
78
|
+
const lastIndex = original.lastIndexOf(old_str);
|
|
79
|
+
if (firstIndex !== lastIndex) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"old_str appears multiple times. Pass replace_all: true, or make old_str more specific/unique."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
updated = original.slice(0, firstIndex) + new_str + original.slice(firstIndex + old_str.length);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await fs.writeFile(filePath, updated, "utf8");
|
|
88
|
+
return { path: filePath, edited: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Delete a single file. */
|
|
92
|
+
export async function deleteFile({ path: filePath }) {
|
|
93
|
+
assertPathAllowed(filePath);
|
|
94
|
+
await fs.unlink(filePath);
|
|
95
|
+
return { path: filePath, deleted: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Move or rename a file/directory. */
|
|
99
|
+
export async function moveFile({ source, destination }) {
|
|
100
|
+
assertPathAllowed(source);
|
|
101
|
+
assertPathAllowed(destination);
|
|
102
|
+
await safeMkdir(path.dirname(destination));
|
|
103
|
+
await fs.rename(source, destination);
|
|
104
|
+
return { source, destination, moved: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Copy a file (not recursive — for directories use copyDirectory). */
|
|
108
|
+
export async function copyFile({ source, destination }) {
|
|
109
|
+
await safeMkdir(path.dirname(destination));
|
|
110
|
+
await fs.copyFile(source, destination);
|
|
111
|
+
return { source, destination, copied: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Get metadata about a file or directory. */
|
|
115
|
+
export async function getFileInfo({ path: targetPath }) {
|
|
116
|
+
const stats = await fs.stat(targetPath);
|
|
117
|
+
return {
|
|
118
|
+
path: targetPath,
|
|
119
|
+
type: stats.isDirectory() ? "directory" : "file",
|
|
120
|
+
size_bytes: stats.size,
|
|
121
|
+
created: stats.birthtime,
|
|
122
|
+
modified: stats.mtime,
|
|
123
|
+
accessed: stats.atime,
|
|
124
|
+
};
|
|
125
|
+
}
|