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.
Files changed (91) hide show
  1. package/.env.example +120 -0
  2. package/CHANGELOG.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +205 -3
  5. package/bin/causly.js +192 -0
  6. package/index.js +229 -0
  7. package/package.json +59 -6
  8. package/packs/calendar.js +141 -0
  9. package/packs/command.js +22 -0
  10. package/packs/database.js +95 -0
  11. package/packs/directory.js +63 -0
  12. package/packs/discord.js +63 -0
  13. package/packs/docker.js +202 -0
  14. package/packs/files.js +106 -0
  15. package/packs/git.js +211 -0
  16. package/packs/github.js +321 -0
  17. package/packs/gmail.js +98 -0
  18. package/packs/leads.js +46 -0
  19. package/packs/linear.js +104 -0
  20. package/packs/loader.js +247 -0
  21. package/packs/manifest.json +1152 -0
  22. package/packs/meetings.js +141 -0
  23. package/packs/notion.js +185 -0
  24. package/packs/pipeline.js +151 -0
  25. package/packs/project.js +70 -0
  26. package/packs/registry.js +149 -0
  27. package/packs/reminders.js +57 -0
  28. package/packs/research.js +34 -0
  29. package/packs/secrets.js +77 -0
  30. package/packs/sentry.js +97 -0
  31. package/packs/seo.js +70 -0
  32. package/packs/slack.js +95 -0
  33. package/packs/stripe.js +127 -0
  34. package/packs/supabase.js +73 -0
  35. package/packs/telegram.js +70 -0
  36. package/packs/terraform.js +247 -0
  37. package/packs/vercel.js +114 -0
  38. package/packs/workflow.js +84 -0
  39. package/prompts/cofounder.js +66 -0
  40. package/prompts/playbooks.js +75 -0
  41. package/tools/backupOps.js +87 -0
  42. package/tools/boundaryAttest.js +184 -0
  43. package/tools/calendarOps.js +563 -0
  44. package/tools/cliOps.js +233 -0
  45. package/tools/commandOps.js +198 -0
  46. package/tools/dbOps.js +117 -0
  47. package/tools/directoryOps.js +96 -0
  48. package/tools/discordOps.js +133 -0
  49. package/tools/dockerOps.js +259 -0
  50. package/tools/envFile.js +89 -0
  51. package/tools/envLoader.js +85 -0
  52. package/tools/fileOps.js +125 -0
  53. package/tools/gitOps.js +170 -0
  54. package/tools/githubOps.js +357 -0
  55. package/tools/gmailOps.js +221 -0
  56. package/tools/googleAuth.js +51 -0
  57. package/tools/googleLogin.js +230 -0
  58. package/tools/leadOps.js +447 -0
  59. package/tools/linearOps.js +304 -0
  60. package/tools/logger.js +47 -0
  61. package/tools/meetingOps.js +697 -0
  62. package/tools/notionOps.js +215 -0
  63. package/tools/paths.js +36 -0
  64. package/tools/pipelineOps.js +511 -0
  65. package/tools/projectOps.js +192 -0
  66. package/tools/reminderAlerts.js +48 -0
  67. package/tools/reminderOps.js +129 -0
  68. package/tools/scheduler.js +46 -0
  69. package/tools/secretsOps.js +133 -0
  70. package/tools/security.js +488 -0
  71. package/tools/sentryOps.js +130 -0
  72. package/tools/seoOps.js +561 -0
  73. package/tools/services.js +295 -0
  74. package/tools/setupOps.js +245 -0
  75. package/tools/slackOps.js +151 -0
  76. package/tools/statusOps.js +104 -0
  77. package/tools/store.js +111 -0
  78. package/tools/stripeOps.js +299 -0
  79. package/tools/supabaseOps.js +94 -0
  80. package/tools/telegramOps.js +153 -0
  81. package/tools/terminal.js +124 -0
  82. package/tools/terraformOps.js +171 -0
  83. package/tools/toolWrapper.js +60 -0
  84. package/tools/toolsetOps.js +123 -0
  85. package/tools/transcribeOps.js +221 -0
  86. package/tools/updateCheck.js +58 -0
  87. package/tools/vercelOps.js +198 -0
  88. package/tools/verifyBoundaryAttest.js +25 -0
  89. package/tools/webOps.js +474 -0
  90. package/tools/workflowOps.js +371 -0
  91. package/tools/workspace.js +85 -0
@@ -0,0 +1,192 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { simpleGit } from "simple-git";
4
+ import { runCommand } from "./commandOps.js";
5
+
6
+ function exists(p) {
7
+ try {
8
+ return fs.existsSync(p);
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ function readJson(p) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(p, "utf8"));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Inspect a directory and identify the project type: language, framework,
24
+ * and package manager. Used by other project_* / run_* tools to decide
25
+ * which commands to run.
26
+ */
27
+ export function detectProject(repo_path) {
28
+ const pkgPath = path.join(repo_path, "package.json");
29
+ if (exists(pkgPath)) {
30
+ const pkg = readJson(pkgPath) || {};
31
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
32
+ let framework = "Node.js";
33
+ if (deps.next) framework = "Next.js";
34
+ else if (deps.react) framework = "React";
35
+ else if (deps.vue) framework = "Vue";
36
+ else if (deps.express) framework = "Express";
37
+ else if (deps["@nestjs/core"]) framework = "NestJS";
38
+
39
+ let package_manager = "npm";
40
+ if (exists(path.join(repo_path, "pnpm-lock.yaml"))) package_manager = "pnpm";
41
+ else if (exists(path.join(repo_path, "yarn.lock"))) package_manager = "yarn";
42
+ else if (exists(path.join(repo_path, "bun.lockb"))) package_manager = "bun";
43
+
44
+ return { language: "JavaScript/TypeScript", framework, package_manager, manifest: "package.json", scripts: pkg.scripts || {} };
45
+ }
46
+
47
+ if (exists(path.join(repo_path, "pyproject.toml"))) {
48
+ const content = fs.readFileSync(path.join(repo_path, "pyproject.toml"), "utf8");
49
+ let framework = "Python";
50
+ if (content.includes("fastapi")) framework = "FastAPI";
51
+ else if (content.includes("django")) framework = "Django";
52
+ else if (content.includes("flask")) framework = "Flask";
53
+ return { language: "Python", framework, package_manager: "pip/poetry", manifest: "pyproject.toml", scripts: {} };
54
+ }
55
+
56
+ if (exists(path.join(repo_path, "requirements.txt"))) {
57
+ const content = fs.readFileSync(path.join(repo_path, "requirements.txt"), "utf8").toLowerCase();
58
+ let framework = "Python";
59
+ if (content.includes("django")) framework = "Django";
60
+ else if (content.includes("fastapi")) framework = "FastAPI";
61
+ else if (content.includes("flask")) framework = "Flask";
62
+ return { language: "Python", framework, package_manager: "pip", manifest: "requirements.txt", scripts: {} };
63
+ }
64
+
65
+ return { language: "unknown", framework: "unknown", package_manager: "unknown", manifest: null, scripts: {} };
66
+ }
67
+
68
+ export async function projectDetect({ repo_path }) {
69
+ return { repo_path, ...detectProject(repo_path) };
70
+ }
71
+
72
+ /** Resolve the concrete command to run for a semantic action (test/lint/typecheck/build). */
73
+ function resolveCommand(repo_path, action) {
74
+ const info = detectProject(repo_path);
75
+
76
+ if (info.manifest === "package.json") {
77
+ const scriptMap = {
78
+ test: ["test"],
79
+ lint: ["lint"],
80
+ typecheck: ["typecheck", "type-check"],
81
+ build: ["build"],
82
+ };
83
+ const candidates = scriptMap[action] || [];
84
+ const found = candidates.find((name) => info.scripts[name]);
85
+ if (found) {
86
+ const runner = info.package_manager === "npm" ? "npm run" : info.package_manager;
87
+ return `${runner} ${found}`;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ if (info.language === "Python") {
93
+ const pyDefaults = {
94
+ test: "pytest",
95
+ lint: "ruff check .",
96
+ typecheck: "mypy .",
97
+ build: "python -m build",
98
+ };
99
+ return pyDefaults[action] || null;
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ async function runSemantic({ repo_path, action, timeout_ms }) {
106
+ const command = resolveCommand(repo_path, action);
107
+ if (!command) {
108
+ return {
109
+ repo_path,
110
+ action,
111
+ ran: false,
112
+ reason: `Could not detect a '${action}' command for this project. Add a matching script to package.json (or the relevant config) and retry.`,
113
+ };
114
+ }
115
+ const result = await runCommand({ command, cwd: repo_path, timeout_ms });
116
+ return { repo_path, action, ran: true, command, ...result };
117
+ }
118
+
119
+ export async function runTests({ repo_path, timeout_ms }) {
120
+ return runSemantic({ repo_path, action: "test", timeout_ms });
121
+ }
122
+
123
+ export async function runLint({ repo_path, timeout_ms }) {
124
+ return runSemantic({ repo_path, action: "lint", timeout_ms });
125
+ }
126
+
127
+ export async function runTypecheck({ repo_path, timeout_ms }) {
128
+ return runSemantic({ repo_path, action: "typecheck", timeout_ms });
129
+ }
130
+
131
+ export async function runBuild({ repo_path, timeout_ms }) {
132
+ return runSemantic({ repo_path, action: "build", timeout_ms });
133
+ }
134
+
135
+ /** Structured project info: detection + git branch/remote + resolved commands. */
136
+ export async function projectInfo({ repo_path }) {
137
+ const detection = detectProject(repo_path);
138
+ let git = null;
139
+ try {
140
+ const g = simpleGit({ baseDir: repo_path });
141
+ const branchSummary = await g.branch();
142
+ const remotes = await g.getRemotes(true);
143
+ git = { current_branch: branchSummary.current, remotes: remotes.map((r) => ({ name: r.name, url: r.refs?.fetch })) };
144
+ } catch {
145
+ git = { error: "not a git repository or git not available" };
146
+ }
147
+
148
+ return {
149
+ repo_path,
150
+ ...detection,
151
+ test_command: resolveCommand(repo_path, "test"),
152
+ lint_command: resolveCommand(repo_path, "lint"),
153
+ typecheck_command: resolveCommand(repo_path, "typecheck"),
154
+ build_command: resolveCommand(repo_path, "build"),
155
+ git,
156
+ };
157
+ }
158
+
159
+ /** High-level health snapshot: git cleanliness, dependency install status, config presence. */
160
+ export async function projectHealth({ repo_path }) {
161
+ const detection = detectProject(repo_path);
162
+
163
+ let git_status = null;
164
+ try {
165
+ const g = simpleGit({ baseDir: repo_path });
166
+ const status = await g.status();
167
+ git_status = { is_clean: status.isClean(), current_branch: status.current, ahead: status.ahead, behind: status.behind };
168
+ } catch {
169
+ git_status = { error: "not a git repository or git not available" };
170
+ }
171
+
172
+ let dependencies_installed = null;
173
+ if (detection.manifest === "package.json") {
174
+ dependencies_installed = exists(path.join(repo_path, "node_modules"));
175
+ } else if (detection.language === "Python") {
176
+ dependencies_installed = exists(path.join(repo_path, ".venv")) || exists(path.join(repo_path, "venv"));
177
+ }
178
+
179
+ const env_files = ["env", "example"]
180
+ .map((suffix) => `.env${suffix === "example" ? ".example" : ""}`)
181
+ .filter((f) => exists(path.join(repo_path, f)));
182
+
183
+ return {
184
+ repo_path,
185
+ language: detection.language,
186
+ framework: detection.framework,
187
+ package_manager: detection.package_manager,
188
+ dependencies_installed,
189
+ env_files_present: env_files,
190
+ git_status,
191
+ };
192
+ }
@@ -0,0 +1,48 @@
1
+ // Makes due reminders reach the user.
2
+ //
3
+ // The reminder job (every minute, while a server process is running) marks a reminder as announced
4
+ // and emits "reminder.due". This module starts that job and, when Telegram is set up, sends the
5
+ // reminder to the owner's chat. Several processes (Claude, Cursor, a custom agent) can run at once:
6
+ // the reminder is claimed atomically in the database, so exactly one of them announces it.
7
+ //
8
+ // Turn off the Telegram message with CAUSLY_REMINDER_NOTIFY=off.
9
+ import { events, runJob } from "./scheduler.js";
10
+ import { startReminderJob } from "./reminderOps.js";
11
+ import * as telegramOps from "./telegramOps.js";
12
+
13
+ let started = false;
14
+
15
+ export const alertsEnabled = (env = process.env) => !/^(off|false|0|no)$/i.test(env.CAUSLY_REMINDER_NOTIFY || "") && Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID);
16
+
17
+ export function reminderText(row) {
18
+ return [`Reminder: ${row.title}`, row.note || null, row.deal_id ? `(deal #${row.deal_id})` : null].filter(Boolean).join("\n");
19
+ }
20
+
21
+ /** Send one due reminder. Never throws: a failed message must not stop the job. */
22
+ export async function deliverReminder(row, deps = {}) {
23
+ const env = deps.env ?? process.env;
24
+ if (!alertsEnabled(env)) return false;
25
+ try {
26
+ await (deps.notify ?? telegramOps.notify)({ text: reminderText(row) });
27
+ return true;
28
+ } catch (err) {
29
+ console.error(`causly-server: could not send reminder #${row.id} to Telegram: ${err.message}`);
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** Start the reminder job once per process, and announce anything already overdue shortly after start. */
35
+ export function ensureReminders() {
36
+ if (started) return;
37
+ started = true;
38
+ startReminderJob();
39
+ events.on("reminder.due", (row) => void deliverReminder(row));
40
+ const first = setTimeout(() => void runJob("reminders-due"), 3000);
41
+ first.unref?.();
42
+ }
43
+
44
+ /** For tests. */
45
+ export function resetReminderAlerts() {
46
+ started = false;
47
+ events.removeAllListeners("reminder.due");
48
+ }
@@ -0,0 +1,129 @@
1
+ // Reminders and follow-ups, stored locally in SQLite. First consumer of the
2
+ // shared store and scheduler. Times are stored as ISO 8601 UTC strings.
3
+ import { getDb, registerMigrations } from "./store.js";
4
+ import { events, schedule } from "./scheduler.js";
5
+
6
+ registerMigrations("reminders", [
7
+ `CREATE TABLE reminders (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ title TEXT NOT NULL,
10
+ note TEXT,
11
+ due_at TEXT NOT NULL,
12
+ status TEXT NOT NULL DEFAULT 'pending',
13
+ notified_at TEXT,
14
+ created_at TEXT NOT NULL,
15
+ updated_at TEXT NOT NULL
16
+ )`,
17
+ `CREATE INDEX idx_reminders_status_due ON reminders (status, due_at)`,
18
+ `ALTER TABLE reminders ADD COLUMN deal_id INTEGER`,
19
+ `CREATE INDEX idx_reminders_deal ON reminders (deal_id)`,
20
+ ]);
21
+
22
+ const STATUSES = ["pending", "done", "cancelled"];
23
+
24
+ const plain = (row) => (row ? { ...row } : row);
25
+
26
+ function resolveDue({ due_at, in_hours }) {
27
+ if (due_at !== undefined && in_hours !== undefined) {
28
+ throw new Error("Pass either due_at or in_hours, not both.");
29
+ }
30
+ if (in_hours !== undefined) {
31
+ if (!Number.isFinite(in_hours)) throw new Error("in_hours must be a number.");
32
+ return new Date(Date.now() + in_hours * 3_600_000).toISOString();
33
+ }
34
+ if (due_at !== undefined) {
35
+ const ms = Date.parse(due_at);
36
+ if (Number.isNaN(ms)) throw new Error(`due_at "${due_at}" is not a valid ISO 8601 date-time.`);
37
+ return new Date(ms).toISOString();
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /** Create a reminder. Give due_at (ISO 8601) or in_hours. */
43
+ export async function reminderCreate({ title, due_at, in_hours, note, deal_id }) {
44
+ if (deal_id !== undefined && !Number.isInteger(deal_id)) throw new Error("deal_id must be a whole number.");
45
+ if (!title || !title.trim()) throw new Error("title is required.");
46
+ const due = resolveDue({ due_at, in_hours });
47
+ if (!due) throw new Error("Pass due_at (ISO 8601) or in_hours.");
48
+ const db = await getDb();
49
+ const now = new Date().toISOString();
50
+ const { lastInsertRowid } = db
51
+ .prepare(
52
+ "INSERT INTO reminders (title, note, due_at, status, created_at, updated_at, deal_id) VALUES (?, ?, ?, 'pending', ?, ?, ?)"
53
+ )
54
+ .run(title.trim(), note ?? null, due, now, now, deal_id ?? null);
55
+ return plain(db.prepare("SELECT * FROM reminders WHERE id = ?").get(Number(lastInsertRowid)));
56
+ }
57
+
58
+ /** List reminders, soonest first. due_only returns only those whose time has come. */
59
+ export async function reminderList({ status = "pending", due_only = false, limit = 50 } = {}) {
60
+ if (status !== "all" && !STATUSES.includes(status)) {
61
+ throw new Error(`status must be one of: ${[...STATUSES, "all"].join(", ")}.`);
62
+ }
63
+ const db = await getDb();
64
+ const now = new Date().toISOString();
65
+ const clauses = [];
66
+ const params = [];
67
+ if (status !== "all") {
68
+ clauses.push("status = ?");
69
+ params.push(status);
70
+ }
71
+ if (due_only) {
72
+ clauses.push("due_at <= ?");
73
+ params.push(now);
74
+ }
75
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
76
+ const capped = Math.min(Math.max(Math.trunc(limit) || 50, 1), 200);
77
+ const rows = db.prepare(`SELECT * FROM reminders ${where} ORDER BY due_at ASC LIMIT ?`).all(...params, capped);
78
+ return { now, count: rows.length, reminders: rows.map(plain) };
79
+ }
80
+
81
+ /** Mark done/cancelled, snooze (new due_at or in_hours), or edit title/note. */
82
+ export async function reminderUpdate({ id, status, due_at, in_hours, title, note }) {
83
+ const db = await getDb();
84
+ const existing = db.prepare("SELECT * FROM reminders WHERE id = ?").get(id);
85
+ if (!existing) throw new Error(`No reminder with id ${id}.`);
86
+ if (status !== undefined && !STATUSES.includes(status)) {
87
+ throw new Error(`status must be one of: ${STATUSES.join(", ")}.`);
88
+ }
89
+ const due = resolveDue({ due_at, in_hours });
90
+ if (status === undefined && due === undefined && title === undefined && note === undefined) {
91
+ throw new Error("Nothing to update: pass status, due_at/in_hours, title or note.");
92
+ }
93
+ // A new time, or reopening, should be able to fire again.
94
+ const rearm = due !== undefined || status === "pending";
95
+ db.prepare("UPDATE reminders SET title = ?, note = ?, status = ?, due_at = ?, notified_at = ?, updated_at = ? WHERE id = ?").run(
96
+ title !== undefined && title.trim() ? title.trim() : existing.title,
97
+ note !== undefined ? note : existing.note,
98
+ status ?? existing.status,
99
+ due ?? existing.due_at,
100
+ rearm ? null : existing.notified_at,
101
+ new Date().toISOString(),
102
+ id
103
+ );
104
+ return plain(db.prepare("SELECT * FROM reminders WHERE id = ?").get(id));
105
+ }
106
+
107
+ /** Scheduler job: announce each pending reminder once when it becomes due. */
108
+ export async function checkDueReminders() {
109
+ const db = await getDb();
110
+ const now = new Date().toISOString();
111
+ const due = db
112
+ .prepare("SELECT * FROM reminders WHERE status = 'pending' AND notified_at IS NULL AND due_at <= ? ORDER BY due_at")
113
+ .all(now);
114
+ let claimed = 0;
115
+ for (const row of due) {
116
+ // Claim it atomically: with several server processes sharing one database, only the one whose UPDATE
117
+ // changes the row announces it.
118
+ const claim = db.prepare("UPDATE reminders SET notified_at = ?, updated_at = ? WHERE id = ? AND notified_at IS NULL").run(now, now, row.id);
119
+ if (Number(claim.changes) !== 1) continue;
120
+ claimed++;
121
+ console.error(`causly-server: reminder due: #${row.id} ${row.title}`);
122
+ events.emit("reminder.due", { ...row, notified_at: now });
123
+ }
124
+ return claimed;
125
+ }
126
+
127
+ export function startReminderJob(intervalMs = 60_000) {
128
+ schedule("reminders-due", intervalMs, checkDueReminders);
129
+ }
@@ -0,0 +1,46 @@
1
+ // Tiny in-process job runner. Jobs run only while the server is running.
2
+ // Packs call schedule() when they load; timers never keep the process alive.
3
+ //
4
+ // `events` is the seam for alerts: a job emits an event (e.g. "reminder.due")
5
+ // and any pack can listen, such as a future Telegram pack that pings you.
6
+ import { EventEmitter } from "events";
7
+
8
+ export const events = new EventEmitter();
9
+
10
+ const jobs = new Map(); // name -> { fn, timer, running }
11
+
12
+ export function schedule(name, intervalMs, fn) {
13
+ unschedule(name);
14
+ const job = { fn, timer: null, running: false };
15
+ job.timer = setInterval(() => runJob(name), intervalMs);
16
+ job.timer.unref?.();
17
+ jobs.set(name, job);
18
+ }
19
+
20
+ /** Run one job now. Skips if it is already running; errors are logged, never thrown. */
21
+ export async function runJob(name) {
22
+ const job = jobs.get(name);
23
+ if (!job || job.running) return;
24
+ job.running = true;
25
+ try {
26
+ await job.fn();
27
+ } catch (err) {
28
+ console.error(`causly-server: scheduled job "${name}" failed: ${err.message}`);
29
+ } finally {
30
+ job.running = false;
31
+ }
32
+ }
33
+
34
+ export function unschedule(name) {
35
+ const job = jobs.get(name);
36
+ if (job) clearInterval(job.timer);
37
+ jobs.delete(name);
38
+ }
39
+
40
+ export function stopScheduler() {
41
+ for (const name of [...jobs.keys()]) unschedule(name);
42
+ }
43
+
44
+ export function listJobs() {
45
+ return [...jobs.keys()];
46
+ }
@@ -0,0 +1,133 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { dataDir } from "./paths.js";
5
+
6
+ const ALGO = "aes-256-gcm";
7
+ // Fixed folder (the repo in a checkout, ~/.causly when installed from npm), whatever directory the server was launched from.
8
+ const DEFAULT_STORE_PATH = path.join(dataDir(), ".causly-secrets.enc");
9
+
10
+ function getMasterKey() {
11
+ const key = process.env.SECRETS_MASTER_KEY;
12
+ if (!key) {
13
+ throw new Error(
14
+ "SECRETS_MASTER_KEY not set. Generate one with `node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"` and add it to your .env file."
15
+ );
16
+ }
17
+ const buf = Buffer.from(key, "hex");
18
+ if (buf.length !== 32) {
19
+ throw new Error("SECRETS_MASTER_KEY must be a 32-byte value encoded as a 64-character hex string.");
20
+ }
21
+ return buf;
22
+ }
23
+
24
+ function storePath(store_path) {
25
+ return store_path || DEFAULT_STORE_PATH;
26
+ }
27
+
28
+ async function loadStore(store_path) {
29
+ const p = storePath(store_path);
30
+ try {
31
+ const raw = await fs.readFile(p, "utf8");
32
+ return JSON.parse(raw);
33
+ } catch (err) {
34
+ if (err.code === "ENOENT") return {};
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ async function saveStore(store_path, data) {
40
+ const p = storePath(store_path);
41
+ await fs.writeFile(p, JSON.stringify(data, null, 2), "utf8");
42
+ }
43
+
44
+ function encrypt(plaintext, key) {
45
+ const iv = crypto.randomBytes(12);
46
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
47
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
48
+ const authTag = cipher.getAuthTag();
49
+ return {
50
+ iv: iv.toString("hex"),
51
+ data: encrypted.toString("hex"),
52
+ tag: authTag.toString("hex"),
53
+ };
54
+ }
55
+
56
+ function decrypt(entry, key) {
57
+ const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(entry.iv, "hex"));
58
+ decipher.setAuthTag(Buffer.from(entry.tag, "hex"));
59
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(entry.data, "hex")), decipher.final()]);
60
+ return decrypted.toString("utf8");
61
+ }
62
+
63
+ /** Store an encrypted secret by name. HIGH risk — requires confirm: true. */
64
+ export async function secretsSet({ name, value, store_path, confirm }) {
65
+ const key = getMasterKey();
66
+ const store = await loadStore(store_path);
67
+ store[name] = {
68
+ ...encrypt(value, key),
69
+ updated_at: new Date().toISOString(),
70
+ };
71
+ await saveStore(store_path, store);
72
+ return { name, saved: true };
73
+ }
74
+
75
+ /** Retrieve and decrypt a secret by name. */
76
+ export async function secretsGet({ name, store_path }) {
77
+ const key = getMasterKey();
78
+ const store = await loadStore(store_path);
79
+ const entry = store[name];
80
+ if (!entry) throw new Error(`Secret "${name}" not found.`);
81
+ return { name, value: decrypt(entry, key), updated_at: entry.updated_at };
82
+ }
83
+
84
+ /** List secret names in the store (never returns values). */
85
+ export async function secretsList({ store_path }) {
86
+ const store = await loadStore(store_path);
87
+ return {
88
+ secrets: Object.entries(store).map(([name, entry]) => ({ name, updated_at: entry.updated_at })),
89
+ };
90
+ }
91
+
92
+ /** Delete a secret by name. DESTRUCTIVE — requires confirm: true. */
93
+ export async function secretsDelete({ name, store_path, confirm }) {
94
+ const store = await loadStore(store_path);
95
+ if (!(name in store)) throw new Error(`Secret "${name}" not found.`);
96
+ delete store[name];
97
+ await saveStore(store_path, store);
98
+ return { name, deleted: true };
99
+ }
100
+
101
+ /**
102
+ * Re-encrypt every secret in the store under a new master key.
103
+ * Call this AFTER setting SECRETS_MASTER_KEY_NEW in your environment.
104
+ * On success, replace SECRETS_MASTER_KEY with the new key's value and remove SECRETS_MASTER_KEY_NEW.
105
+ * HIGH risk — requires confirm: true.
106
+ */
107
+ export async function secretsRotateKey({ store_path, confirm }) {
108
+ const oldKey = getMasterKey();
109
+ const newKeyHex = process.env.SECRETS_MASTER_KEY_NEW;
110
+ if (!newKeyHex) {
111
+ throw new Error(
112
+ "SECRETS_MASTER_KEY_NEW not set. Generate a new key and add it to your .env before rotating."
113
+ );
114
+ }
115
+ const newKey = Buffer.from(newKeyHex, "hex");
116
+ if (newKey.length !== 32) {
117
+ throw new Error("SECRETS_MASTER_KEY_NEW must be a 32-byte value encoded as a 64-character hex string.");
118
+ }
119
+
120
+ const store = await loadStore(store_path);
121
+ const rotated = {};
122
+ for (const [name, entry] of Object.entries(store)) {
123
+ const plaintext = decrypt(entry, oldKey);
124
+ rotated[name] = { ...encrypt(plaintext, newKey), updated_at: entry.updated_at };
125
+ }
126
+ await saveStore(store_path, rotated);
127
+ return { rotated_count: Object.keys(rotated).length, note: "Now replace SECRETS_MASTER_KEY with SECRETS_MASTER_KEY_NEW's value in your .env, and remove SECRETS_MASTER_KEY_NEW." };
128
+ }
129
+
130
+ /** Generate a new random 32-byte master key (hex-encoded) — use to initialize SECRETS_MASTER_KEY or for rotation. */
131
+ export async function secretsGenerateKey() {
132
+ return { key: crypto.randomBytes(32).toString("hex") };
133
+ }