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,94 @@
|
|
|
1
|
+
const SUPABASE_API = "https://api.supabase.com/v1";
|
|
2
|
+
|
|
3
|
+
function getToken() {
|
|
4
|
+
const token = process.env.SUPABASE_ACCESS_TOKEN;
|
|
5
|
+
if (!token) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"SUPABASE_ACCESS_TOKEN not set. Run `npm run setup` to configure it, or add it manually to your .env file: SUPABASE_ACCESS_TOKEN=sbp_..."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return token;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function supabaseFetch(path, options = {}) {
|
|
14
|
+
const token = getToken();
|
|
15
|
+
const res = await fetch(`${SUPABASE_API}${path}`, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${token}`,
|
|
19
|
+
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
20
|
+
...options.headers,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
let data;
|
|
26
|
+
try {
|
|
27
|
+
data = text ? JSON.parse(text) : null;
|
|
28
|
+
} catch {
|
|
29
|
+
data = text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const message = data?.message || res.statusText;
|
|
34
|
+
throw new Error(`Supabase API error (${res.status}): ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** List organizations the authenticated account belongs to. Needed to create a new project. */
|
|
41
|
+
export async function supabaseListOrganizations() {
|
|
42
|
+
const data = await supabaseFetch("/organizations");
|
|
43
|
+
return { organizations: data.map((o) => ({ id: o.id, name: o.name })) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** List projects for the authenticated account. */
|
|
47
|
+
export async function supabaseListProjects() {
|
|
48
|
+
const data = await supabaseFetch("/projects");
|
|
49
|
+
return {
|
|
50
|
+
projects: data.map((p) => ({
|
|
51
|
+
id: p.id,
|
|
52
|
+
name: p.name,
|
|
53
|
+
region: p.region,
|
|
54
|
+
status: p.status,
|
|
55
|
+
organization_id: p.organization_id,
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get details of a single project by its ref/ID. */
|
|
61
|
+
export async function supabaseGetProject({ project_ref }) {
|
|
62
|
+
const data = await supabaseFetch(`/projects/${project_ref}`);
|
|
63
|
+
return { id: data.id, name: data.name, region: data.region, status: data.status };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a new Supabase project. Requires an organization_id
|
|
68
|
+
* (use supabase_list_organizations to find it) and a database password.
|
|
69
|
+
*/
|
|
70
|
+
export async function supabaseCreateProject({ name, organization_id, db_pass, region = "ap-south-1", plan = "free" }) {
|
|
71
|
+
const data = await supabaseFetch("/projects", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: JSON.stringify({ name, organization_id, db_pass, region, plan }),
|
|
74
|
+
});
|
|
75
|
+
return { id: data.id, name: data.name, region: data.region, status: data.status };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Delete a Supabase project. */
|
|
79
|
+
export async function supabaseDeleteProject({ project_ref }) {
|
|
80
|
+
await supabaseFetch(`/projects/${project_ref}`, { method: "DELETE" });
|
|
81
|
+
return { project_ref, deleted: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run raw SQL against a project's database — the way to create tables,
|
|
86
|
+
* alter schema, seed data, or run arbitrary queries.
|
|
87
|
+
*/
|
|
88
|
+
export async function supabaseRunSql({ project_ref, query }) {
|
|
89
|
+
const data = await supabaseFetch(`/projects/${project_ref}/database/query`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: JSON.stringify({ query }),
|
|
92
|
+
});
|
|
93
|
+
return { project_ref, result: data };
|
|
94
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Telegram Bot API tools. No new dependencies; a bot token is all that is needed.
|
|
2
|
+
//
|
|
3
|
+
// telegram_get_me check the bot token, show the bot's name
|
|
4
|
+
// telegram_get_updates recent messages sent to the bot (also how you find a chat id)
|
|
5
|
+
// telegram_get_chat details of one chat
|
|
6
|
+
// telegram_notify send a message to YOUR chat (TELEGRAM_CHAT_ID): alerts, "lead replied"
|
|
7
|
+
// telegram_send_message send a message to any chat (HIGH risk, needs confirm: true)
|
|
8
|
+
//
|
|
9
|
+
// Credentials: TELEGRAM_BOT_TOKEN (from @BotFather). TELEGRAM_CHAT_ID is the chat that
|
|
10
|
+
// telegram_notify writes to; get it by messaging your bot and calling telegram_get_updates.
|
|
11
|
+
import { UNTRUSTED_NOTICE } from "./webOps.js";
|
|
12
|
+
|
|
13
|
+
const API = "https://api.telegram.org";
|
|
14
|
+
const MAX_TEXT = 4096;
|
|
15
|
+
const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
|
|
16
|
+
const CHAT_ID = /^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,31})$/;
|
|
17
|
+
|
|
18
|
+
function token(env = process.env) {
|
|
19
|
+
const t = env.TELEGRAM_BOT_TOKEN;
|
|
20
|
+
if (!t) throw new Error("Telegram is not set up. Missing: TELEGRAM_BOT_TOKEN (create a bot with @BotFather and add the token to .env).");
|
|
21
|
+
return t;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scrub = (text, t) => String(text).split(t).join("[REDACTED]");
|
|
25
|
+
|
|
26
|
+
/** One Bot API call. Errors never contain the bot token. */
|
|
27
|
+
async function call(method, params, deps = {}) {
|
|
28
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
29
|
+
const t = token();
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetchFn(`${API}/bot${t}/${method}`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "content-type": "application/json" },
|
|
35
|
+
body: JSON.stringify(params ?? {}),
|
|
36
|
+
signal: AbortSignal.timeout(20_000),
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`Could not reach Telegram: ${scrub(err.message, t)}`);
|
|
40
|
+
}
|
|
41
|
+
const data = await res.json().catch(() => ({}));
|
|
42
|
+
if (data.ok) return data.result;
|
|
43
|
+
|
|
44
|
+
const why = scrub(data.description || `HTTP ${res.status}`, t);
|
|
45
|
+
const code = data.error_code ?? res.status;
|
|
46
|
+
if (code === 401) throw new Error("Telegram rejected the bot token. Check TELEGRAM_BOT_TOKEN.");
|
|
47
|
+
if (code === 409) throw new Error("This bot has a webhook set, which blocks reading updates here. Remove the webhook (deleteWebhook) to use telegram_get_updates.");
|
|
48
|
+
if (code === 403) throw new Error(`Telegram refused: ${why}. For a person, they must open the bot and press Start first; for a group or channel, add the bot to it.`);
|
|
49
|
+
if (code === 429) {
|
|
50
|
+
const wait = data.parameters?.retry_after;
|
|
51
|
+
throw new Error(`Telegram rate limit hit${wait ? `; retry in ${wait}s` : ""}.`);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Telegram error: ${why}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkChatId(id, label = "chat_id") {
|
|
57
|
+
const s = String(id ?? "").trim();
|
|
58
|
+
if (!CHAT_ID.test(s)) throw new Error(`${label} must be a number like 123456789 (groups are negative) or a channel like @mychannel.`);
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkText(text) {
|
|
63
|
+
const s = String(text ?? "");
|
|
64
|
+
if (!s.trim()) throw new Error("text is required.");
|
|
65
|
+
if (s.length > MAX_TEXT) throw new Error(`text is ${s.length} characters; Telegram allows ${MAX_TEXT}. Shorten it or send it in parts.`);
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PARSE_MODES = ["HTML", "MarkdownV2"];
|
|
70
|
+
|
|
71
|
+
async function send({ chat_id, text, reply_to_message_id, parse_mode, silent }, deps) {
|
|
72
|
+
const body = {
|
|
73
|
+
chat_id,
|
|
74
|
+
text: checkText(text),
|
|
75
|
+
disable_web_page_preview: true,
|
|
76
|
+
...(silent ? { disable_notification: true } : {}),
|
|
77
|
+
...(reply_to_message_id !== undefined ? { reply_parameters: { message_id: Number(reply_to_message_id), allow_sending_without_reply: true } } : {}),
|
|
78
|
+
};
|
|
79
|
+
if (parse_mode !== undefined) {
|
|
80
|
+
if (!PARSE_MODES.includes(parse_mode)) throw new Error("parse_mode must be HTML or MarkdownV2 (leave it out for plain text).");
|
|
81
|
+
body.parse_mode = parse_mode;
|
|
82
|
+
}
|
|
83
|
+
const m = await call("sendMessage", body, deps);
|
|
84
|
+
return { sent: true, chat_id: m.chat?.id, message_id: m.message_id, date: new Date(m.date * 1000).toISOString() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------- tools
|
|
88
|
+
|
|
89
|
+
export async function getMe(_input = {}, deps = {}) {
|
|
90
|
+
const me = await call("getMe", {}, deps);
|
|
91
|
+
return {
|
|
92
|
+
id: me.id,
|
|
93
|
+
username: me.username,
|
|
94
|
+
name: me.first_name,
|
|
95
|
+
can_join_groups: me.can_join_groups,
|
|
96
|
+
notify_chat_configured: Boolean(process.env.TELEGRAM_CHAT_ID),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const person = (u) => (u ? { id: u.id, ...(u.username ? { username: u.username } : {}), name: [u.first_name, u.last_name].filter(Boolean).join(" ") } : undefined);
|
|
101
|
+
|
|
102
|
+
function simplifyUpdate(u) {
|
|
103
|
+
const m = u.message ?? u.channel_post;
|
|
104
|
+
if (!m) return { update_id: u.update_id, kind: "other" };
|
|
105
|
+
return {
|
|
106
|
+
update_id: u.update_id,
|
|
107
|
+
kind: u.message ? "message" : "channel_post",
|
|
108
|
+
message_id: m.message_id,
|
|
109
|
+
chat: { id: m.chat.id, type: m.chat.type, ...(m.chat.title ? { title: m.chat.title } : {}), ...(m.chat.username ? { username: m.chat.username } : {}) },
|
|
110
|
+
from: person(m.from),
|
|
111
|
+
date: new Date(m.date * 1000).toISOString(),
|
|
112
|
+
text: m.text ?? m.caption ?? (m.photo ? "[photo]" : m.document ? "[document]" : m.voice ? "[voice message]" : "[non-text message]"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function getUpdates({ limit = 20, offset } = {}, deps = {}) {
|
|
117
|
+
const params = { limit: clamp(Math.trunc(Number(limit)) || 20, 1, 100), timeout: 0, allowed_updates: ["message", "channel_post"] };
|
|
118
|
+
if (offset !== undefined) params.offset = Math.trunc(Number(offset));
|
|
119
|
+
const raw = await call("getUpdates", params, deps);
|
|
120
|
+
const updates = raw.map(simplifyUpdate);
|
|
121
|
+
return {
|
|
122
|
+
count: updates.length,
|
|
123
|
+
updates,
|
|
124
|
+
...(raw.length ? { next_offset: raw[raw.length - 1].update_id + 1 } : {}),
|
|
125
|
+
hint: "Pass next_offset as offset to mark these as read; they will not be returned again. Without offset they stay in the queue (kept for 24 hours).",
|
|
126
|
+
notice: UNTRUSTED_NOTICE,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function getChat({ chat_id } = {}, deps = {}) {
|
|
131
|
+
const c = await call("getChat", { chat_id: checkChatId(chat_id) }, deps);
|
|
132
|
+
return {
|
|
133
|
+
id: c.id,
|
|
134
|
+
type: c.type,
|
|
135
|
+
...(c.title ? { title: c.title } : {}),
|
|
136
|
+
...(c.username ? { username: c.username } : {}),
|
|
137
|
+
...(c.first_name ? { name: [c.first_name, c.last_name].filter(Boolean).join(" ") } : {}),
|
|
138
|
+
...(c.description ? { description: String(c.description).slice(0, 500) } : {}),
|
|
139
|
+
notice: UNTRUSTED_NOTICE,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Message to the owner's own chat. Cannot reach anyone else, so no confirmation is needed. */
|
|
144
|
+
export async function notify({ text, silent, parse_mode } = {}, deps = {}) {
|
|
145
|
+
const chat = process.env.TELEGRAM_CHAT_ID;
|
|
146
|
+
if (!chat) throw new Error("TELEGRAM_CHAT_ID is not set. Message your bot, call telegram_get_updates to see your chat id, and add it to .env.");
|
|
147
|
+
return send({ chat_id: checkChatId(chat, "TELEGRAM_CHAT_ID"), text, silent, parse_mode }, deps);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Message to any chat. HIGH risk: the wrapper requires confirm: true. */
|
|
151
|
+
export async function sendMessage({ chat_id, text, reply_to_message_id, parse_mode, silent, confirm } = {}, deps = {}) {
|
|
152
|
+
return send({ chat_id: checkChatId(chat_id), text, reply_to_message_id, parse_mode, silent }, deps);
|
|
153
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Small interactive prompts for `causly-server setup` and `keys set`.
|
|
2
|
+
// In a real terminal: raw key input, so a pasted secret shows as * and never echoes.
|
|
3
|
+
// Piped input (scripts, CI): plain lines, so `echo $KEY | causly-server keys set NAME` works.
|
|
4
|
+
|
|
5
|
+
export class Cancelled extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("Cancelled.");
|
|
8
|
+
this.name = "Cancelled";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createTerminal({ input = process.stdin, output = process.stdout } = {}) {
|
|
13
|
+
const tty = Boolean(input.isTTY && typeof input.setRawMode === "function");
|
|
14
|
+
|
|
15
|
+
// ---- non-interactive: queue of lines
|
|
16
|
+
const queue = [];
|
|
17
|
+
const waiters = [];
|
|
18
|
+
let ended = false;
|
|
19
|
+
let pending = "";
|
|
20
|
+
const pushLine = (line) => {
|
|
21
|
+
const w = waiters.shift();
|
|
22
|
+
if (w) w(line);
|
|
23
|
+
else queue.push(line);
|
|
24
|
+
};
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
pending += chunk;
|
|
27
|
+
let i;
|
|
28
|
+
while ((i = pending.search(/\r?\n/)) !== -1) {
|
|
29
|
+
pushLine(pending.slice(0, i));
|
|
30
|
+
pending = pending.slice(pending.indexOf("\n", i) + 1);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const onEnd = () => {
|
|
34
|
+
ended = true;
|
|
35
|
+
if (pending) pushLine(pending);
|
|
36
|
+
pending = "";
|
|
37
|
+
while (waiters.length) waiters.shift()(null);
|
|
38
|
+
};
|
|
39
|
+
let piped = false;
|
|
40
|
+
const startPiped = () => {
|
|
41
|
+
if (piped) return;
|
|
42
|
+
piped = true;
|
|
43
|
+
input.setEncoding?.("utf8");
|
|
44
|
+
input.on("data", onData);
|
|
45
|
+
input.on("end", onEnd);
|
|
46
|
+
input.resume?.();
|
|
47
|
+
};
|
|
48
|
+
const nextLine = () => {
|
|
49
|
+
startPiped();
|
|
50
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
51
|
+
if (ended) return Promise.resolve(null);
|
|
52
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---- interactive: one raw-mode read
|
|
56
|
+
const readRaw = (secret) =>
|
|
57
|
+
new Promise((resolve, reject) => {
|
|
58
|
+
let value = "";
|
|
59
|
+
input.setRawMode(true);
|
|
60
|
+
input.setEncoding?.("utf8");
|
|
61
|
+
input.resume();
|
|
62
|
+
const finish = (fn, arg) => {
|
|
63
|
+
input.removeListener("data", onKey);
|
|
64
|
+
input.setRawMode(false);
|
|
65
|
+
input.pause?.();
|
|
66
|
+
output.write("\n");
|
|
67
|
+
fn(arg);
|
|
68
|
+
};
|
|
69
|
+
const onKey = (chunk) => {
|
|
70
|
+
const s = String(chunk);
|
|
71
|
+
if (s.startsWith("\u001b")) return; // arrow keys and other escape sequences
|
|
72
|
+
for (const ch of s) {
|
|
73
|
+
if (ch === "\r" || ch === "\n") return finish(resolve, value);
|
|
74
|
+
if (ch === "\u0003") return finish(reject, new Cancelled()); // Ctrl+C
|
|
75
|
+
if (ch === "\u0004" && !value) return finish(reject, new Cancelled()); // Ctrl+D on an empty line
|
|
76
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
77
|
+
if (value) {
|
|
78
|
+
value = value.slice(0, -1);
|
|
79
|
+
output.write("\b \b");
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch < " ") continue;
|
|
84
|
+
value += ch;
|
|
85
|
+
output.write(secret ? "*" : ch);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
input.on("data", onKey);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const ask = async (question, { secret = false } = {}) => {
|
|
92
|
+
output.write(question);
|
|
93
|
+
if (tty) return readRaw(secret);
|
|
94
|
+
const line = await nextLine();
|
|
95
|
+
output.write("\n");
|
|
96
|
+
if (line === null) throw new Cancelled();
|
|
97
|
+
return line;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
interactive: tty,
|
|
102
|
+
write: (text) => output.write(text),
|
|
103
|
+
line: (question) => ask(question),
|
|
104
|
+
secret: (question) => ask(question, { secret: true }),
|
|
105
|
+
/** Yes/no. Enter picks the default. */
|
|
106
|
+
async confirm(question, defaultYes = true) {
|
|
107
|
+
const a = (await ask(`${question} ${defaultYes ? "(Y/n) " : "(y/N) "}`)).trim().toLowerCase();
|
|
108
|
+
if (!a) return defaultYes;
|
|
109
|
+
return a === "y" || a === "yes";
|
|
110
|
+
},
|
|
111
|
+
/** Reads whatever is piped in (first line), for `keys set` in scripts. */
|
|
112
|
+
async readPipedLine() {
|
|
113
|
+
const line = await nextLine();
|
|
114
|
+
return line === null ? "" : line;
|
|
115
|
+
},
|
|
116
|
+
close() {
|
|
117
|
+
if (piped) {
|
|
118
|
+
input.removeListener("data", onData);
|
|
119
|
+
input.removeListener("end", onEnd);
|
|
120
|
+
input.pause?.();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import * as githubOps from "./githubOps.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
async function runTerraform(args, cwd, timeout_ms = 120000) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout, stderr } = await execFileAsync("terraform", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
timeout: timeout_ms,
|
|
12
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
13
|
+
});
|
|
14
|
+
return { stdout, stderr, exit_code: 0 };
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return {
|
|
17
|
+
stdout: err.stdout || "",
|
|
18
|
+
stderr: err.stderr || err.message,
|
|
19
|
+
exit_code: err.code ?? 1,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Initialize a Terraform working directory (downloads providers/modules). */
|
|
25
|
+
export async function terraformInit({ dir, upgrade = false }) {
|
|
26
|
+
const args = ["init", "-no-color"];
|
|
27
|
+
if (upgrade) args.push("-upgrade");
|
|
28
|
+
return runTerraform(args, dir);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate the configuration's syntax and internal consistency. */
|
|
32
|
+
export async function terraformValidate({ dir }) {
|
|
33
|
+
return runTerraform(["validate", "-no-color", "-json"], dir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Format .tf files to canonical style. */
|
|
37
|
+
export async function terraformFmt({ dir, check = false }) {
|
|
38
|
+
const args = ["fmt", "-no-color"];
|
|
39
|
+
if (check) args.push("-check");
|
|
40
|
+
return runTerraform(args, dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Show an execution plan: what Terraform would change. */
|
|
44
|
+
export async function terraformPlan({ dir, var_file, out }) {
|
|
45
|
+
const args = ["plan", "-no-color", "-input=false"];
|
|
46
|
+
if (var_file) args.push(`-var-file=${var_file}`);
|
|
47
|
+
if (out) args.push(`-out=${out}`);
|
|
48
|
+
return runTerraform(args, dir, 300000);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Apply changes to reach the desired state. HIGH risk — requires confirm: true. */
|
|
52
|
+
export async function terraformApply({ dir, var_file, plan_file, confirm }) {
|
|
53
|
+
const args = ["apply", "-no-color", "-input=false", "-auto-approve"];
|
|
54
|
+
if (plan_file) {
|
|
55
|
+
args.push(plan_file);
|
|
56
|
+
} else if (var_file) {
|
|
57
|
+
args.push(`-var-file=${var_file}`);
|
|
58
|
+
}
|
|
59
|
+
return runTerraform(args, dir, 600000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Destroy all resources managed by this configuration. HIGH risk — requires confirm: true. */
|
|
63
|
+
export async function terraformDestroy({ dir, var_file, confirm }) {
|
|
64
|
+
const args = ["destroy", "-no-color", "-input=false", "-auto-approve"];
|
|
65
|
+
if (var_file) args.push(`-var-file=${var_file}`);
|
|
66
|
+
return runTerraform(args, dir, 600000);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Show the current state or a saved plan, human-readable or as JSON. */
|
|
70
|
+
export async function terraformShow({ dir, target, json = false }) {
|
|
71
|
+
const args = ["show", "-no-color"];
|
|
72
|
+
if (json) args.push("-json");
|
|
73
|
+
if (target) args.push(target);
|
|
74
|
+
return runTerraform(args, dir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read output values from the root module's state. */
|
|
78
|
+
export async function terraformOutput({ dir, name, json = true }) {
|
|
79
|
+
const args = ["output", "-no-color"];
|
|
80
|
+
if (json) args.push("-json");
|
|
81
|
+
if (name) args.push(name);
|
|
82
|
+
return runTerraform(args, dir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Manage Terraform workspaces (environments like dev/staging/prod). action: 'list' | 'new' | 'select' | 'delete' (defaults to list). */
|
|
86
|
+
export async function terraformWorkspace({ dir, action = "list", name }) {
|
|
87
|
+
if (action === "list") return runTerraform(["workspace", "list", "-no-color"], dir);
|
|
88
|
+
if (action === "new") return runTerraform(["workspace", "new", "-no-color", name], dir);
|
|
89
|
+
if (action === "select") return runTerraform(["workspace", "select", "-no-color", name], dir);
|
|
90
|
+
if (action === "delete") return runTerraform(["workspace", "delete", "-no-color", name], dir);
|
|
91
|
+
throw new Error(`Unknown workspace action: ${action}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** List all resources tracked in the current state. */
|
|
95
|
+
export async function terraformStateList({ dir, filter }) {
|
|
96
|
+
const args = ["state", "list", "-no-color"];
|
|
97
|
+
if (filter) args.push(filter);
|
|
98
|
+
return runTerraform(args, dir);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Show detailed attributes of a single resource in the state. */
|
|
102
|
+
export async function terraformStateShow({ dir, address }) {
|
|
103
|
+
return runTerraform(["state", "show", "-no-color", address], dir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Move a resource to a new address within the state (rename/refactor without destroy+recreate). HIGH risk — requires confirm: true. */
|
|
107
|
+
export async function terraformStateMv({ dir, source, destination, confirm }) {
|
|
108
|
+
return runTerraform(["state", "mv", "-no-color", source, destination], dir);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Remove a resource from the state without destroying the real infrastructure (stops Terraform managing it). HIGH risk — requires confirm: true. */
|
|
112
|
+
export async function terraformStateRm({ dir, address, confirm }) {
|
|
113
|
+
return runTerraform(["state", "rm", "-no-color", address], dir);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Download and print the raw remote state as JSON — useful for backend/state-drift inspection. */
|
|
117
|
+
export async function terraformStatePull({ dir }) {
|
|
118
|
+
return runTerraform(["state", "pull"], dir);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Import an existing real-world resource into Terraform state, so it becomes managed by this config. HIGH risk — requires confirm: true. */
|
|
122
|
+
export async function terraformImport({ dir, address, resource_id, var_file, confirm }) {
|
|
123
|
+
const args = ["import", "-no-color", "-input=false"];
|
|
124
|
+
if (var_file) args.push(`-var-file=${var_file}`);
|
|
125
|
+
args.push(address, resource_id);
|
|
126
|
+
return runTerraform(args, dir, 300000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Mark a resource as tainted, forcing it to be destroyed and recreated on the next apply. HIGH risk — requires confirm: true. */
|
|
130
|
+
export async function terraformTaint({ dir, address, confirm }) {
|
|
131
|
+
return runTerraform(["taint", "-no-color", address], dir);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Remove the tainted mark from a resource, so it will not be forcibly recreated. */
|
|
135
|
+
export async function terraformUntaint({ dir, address }) {
|
|
136
|
+
return runTerraform(["untaint", "-no-color", address], dir);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Generate a visual dependency graph of resources in DOT format. */
|
|
140
|
+
export async function terraformGraph({ dir }) {
|
|
141
|
+
return runTerraform(["graph"], dir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** List the providers required by the configuration and their resolved versions. */
|
|
145
|
+
export async function terraformProviders({ dir }) {
|
|
146
|
+
return runTerraform(["providers"], dir);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* CI/CD hook: run `terraform plan`, format a concise Markdown summary, and post it
|
|
151
|
+
* as a comment on a GitHub pull request — so reviewers see the infra diff before approving.
|
|
152
|
+
* HIGH risk — requires confirm: true.
|
|
153
|
+
*/
|
|
154
|
+
export async function terraformPlanComment({ dir, var_file, owner, repo, pull_number, confirm }) {
|
|
155
|
+
const args = ["plan", "-no-color", "-input=false", "-detailed-exitcode"];
|
|
156
|
+
if (var_file) args.push(`-var-file=${var_file}`);
|
|
157
|
+
const planResult = await runTerraform(args, dir, 300000);
|
|
158
|
+
|
|
159
|
+
// -detailed-exitcode: 0 = no changes, 1 = error, 2 = changes present. Both 0 and 2 are "success" runs.
|
|
160
|
+
const hadError = planResult.exit_code !== 0 && planResult.exit_code !== 2;
|
|
161
|
+
|
|
162
|
+
const status = hadError ? "❌ Plan failed" : planResult.exit_code === 0 ? "✅ No changes" : "📋 Plan has changes";
|
|
163
|
+
const body =
|
|
164
|
+
`### Terraform Plan — ${status}\n\n` +
|
|
165
|
+
"<details><summary>Show plan output</summary>\n\n```\n" +
|
|
166
|
+
(hadError ? planResult.stderr : planResult.stdout).slice(0, 60000) +
|
|
167
|
+
"\n```\n</details>";
|
|
168
|
+
|
|
169
|
+
const comment = await githubOps.githubAddComment({ owner, repo, issue_number: pull_number, body });
|
|
170
|
+
return { plan_exit_code: planResult.exit_code, had_error: hadError, comment_url: comment.html_url };
|
|
171
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { checkApproval } from "./security.js";
|
|
3
|
+
import { logActivity } from "./logger.js";
|
|
4
|
+
import { checkWorkspace } from "./workspace.js";
|
|
5
|
+
import { emitBoundaryAttestReceipt } from "./boundaryAttest.js";
|
|
6
|
+
import { reloadEnvIfChanged } from "./envLoader.js";
|
|
7
|
+
import { setupHint } from "./services.js";
|
|
8
|
+
|
|
9
|
+
function reportAttestationFailure(toolName, operationId, err) {
|
|
10
|
+
console.error(`BoundaryAttest error for ${toolName} (${operationId}): ${err.message}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createToolWrapper({
|
|
14
|
+
approvalCheck = checkApproval,
|
|
15
|
+
workspaceCheck = checkWorkspace,
|
|
16
|
+
activityLogger = logActivity,
|
|
17
|
+
receiptEmitter = emitBoundaryAttestReceipt,
|
|
18
|
+
operationIdFactory = () => crypto.randomUUID(),
|
|
19
|
+
attestationErrorReporter = reportAttestationFailure,
|
|
20
|
+
envRefresher = reloadEnvIfChanged,
|
|
21
|
+
errorHinter = setupHint,
|
|
22
|
+
} = {}) {
|
|
23
|
+
return function wrap(toolName, handler) {
|
|
24
|
+
return async (input) => {
|
|
25
|
+
try { envRefresher(); } catch { /* a settings-file problem must never block a tool */ }
|
|
26
|
+
const operationId = operationIdFactory();
|
|
27
|
+
const attest = (evidence) => {
|
|
28
|
+
try { receiptEmitter({ toolName, input, operationId, ...evidence }); }
|
|
29
|
+
catch (err) { attestationErrorReporter(toolName, operationId, err); }
|
|
30
|
+
};
|
|
31
|
+
const workspace = workspaceCheck(toolName, input);
|
|
32
|
+
if (!workspace.allowed) {
|
|
33
|
+
activityLogger(toolName, input, "BLOCKED", workspace.reason, null, operationId);
|
|
34
|
+
attest({ status: "BLOCKED", error: workspace.reason });
|
|
35
|
+
return { content: [{ type: "text", text: `Blocked: ${workspace.reason}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
const approval = approvalCheck(toolName, input);
|
|
38
|
+
if (!approval.allowed) {
|
|
39
|
+
activityLogger(toolName, input, "BLOCKED", approval.reason, null, operationId);
|
|
40
|
+
attest({ status: "BLOCKED", error: approval.reason });
|
|
41
|
+
return { content: [{ type: "text", text: `Blocked: ${approval.reason}` }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
try {
|
|
45
|
+
const result = await handler(input);
|
|
46
|
+
activityLogger(toolName, input, "SUCCESS", "", Date.now() - startedAt, operationId);
|
|
47
|
+
attest({ status: "SUCCESS", result });
|
|
48
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
activityLogger(toolName, input, "ERROR", err.message, Date.now() - startedAt, operationId);
|
|
51
|
+
attest({ status: "ERROR", error: err.message });
|
|
52
|
+
let hint = null;
|
|
53
|
+
try { hint = errorHinter(err.message); } catch { /* hint is optional */ }
|
|
54
|
+
return { content: [{ type: "text", text: `Error: ${err.message}${hint ? `\n${hint}` : ""}` }], isError: true };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const wrap = createToolWrapper();
|