total-recall-brain 3.32.2 → 3.32.4
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/package.json +2 -1
- package/plugins/operator-alerts/README.md +27 -0
- package/plugins/operator-alerts/cli.mjs +57 -0
- package/plugins/operator-alerts/notify-core.mjs +193 -0
- package/plugins/operator-alerts/plugin.json +20 -0
- package/src/cli/command.mjs +44 -2
- package/src/cli/plugin/install.mjs +6 -1
- package/src/cli/plugin/remove.mjs +5 -1
- package/src/core/env-import.mjs +4 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "total-recall-brain",
|
|
3
|
-
"version": "3.32.
|
|
3
|
+
"version": "3.32.4",
|
|
4
4
|
"description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"frontend/src/",
|
|
24
24
|
"extension/",
|
|
25
25
|
"plugins/",
|
|
26
|
+
"!plugins/**/*.spec.mjs",
|
|
26
27
|
"templates/",
|
|
27
28
|
"scaffold/",
|
|
28
29
|
"docs/ARCHITECTURE.md",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Operator Alerts
|
|
2
|
+
|
|
3
|
+
Operator alerts from agents, scripts and scheduled tasks.
|
|
4
|
+
|
|
5
|
+
**Channels:** `os` (terminal-notifier, osascript, notify-send), `email`
|
|
6
|
+
(SMTP2GO), `sms` (Telnyx), `webhook`, `github`. `slack`, `discord`, `telegram`
|
|
7
|
+
and `expo` are stubs: they report `stub` and send nothing.
|
|
8
|
+
|
|
9
|
+
**Config** (later wins): `~/.total-recall/notifications/config.json`,
|
|
10
|
+
`<repo>/.agent/notify.json`, `--config <file>`.
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"email": { "key_secret": "SMTP2GO_KEY", "to": "secret:OPERATOR_EMAIL", "from": "alerts@your-verified-domain" },
|
|
15
|
+
"sms": { "key_secret": "TELNYX_KEY", "to": "secret:OPERATOR_PHONE", "from": "+1XXXXXXXXXX" }
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`*_secret` values name Total Recall secrets read with `secret get` in-process.
|
|
20
|
+
`to` is a literal or `secret:NAME`.
|
|
21
|
+
|
|
22
|
+
**Commands:** `alerts send|test|config|log`. `status: accepted` means the
|
|
23
|
+
provider took it, not that it arrived. Ledger: `sent.jsonl` beside the log in
|
|
24
|
+
`~/.total-recall/notifications/` (override with `TR_NOTIFY_HOME`).
|
|
25
|
+
|
|
26
|
+
**Network access:** api.smtp2go.com, api.telnyx.com, api.github.com, and the
|
|
27
|
+
webhook URL you configure. Nothing else.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sendNotification, loadConfig } from './notify-core.mjs';
|
|
4
|
+
|
|
5
|
+
const USAGE = `usage:
|
|
6
|
+
total-recall alerts send --title T --message M [--channels os,email,sms,webhook,github] [--event-id ID]
|
|
7
|
+
[--severity info|high|critical] [--source S] [--quiet-hours 22-7] [--config file] [--dry-run]
|
|
8
|
+
total-recall alerts test [--channels ...] dry-run every configured channel
|
|
9
|
+
total-recall alerts config show the merged config (secret names only)
|
|
10
|
+
total-recall alerts log [--limit N] recent sends from the ledger`;
|
|
11
|
+
|
|
12
|
+
function flags(args) {
|
|
13
|
+
const a = { _: [] };
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const t = args[i];
|
|
16
|
+
if (t === '--dry-run') a.dry = true;
|
|
17
|
+
else if (t.startsWith('--')) a[t.slice(2)] = args[++i];
|
|
18
|
+
else a._.push(t);
|
|
19
|
+
}
|
|
20
|
+
return a;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function run(argv = []) {
|
|
24
|
+
const args = Array.isArray(argv) ? argv.slice(3) : [];
|
|
25
|
+
const sub = args[0] || 'help';
|
|
26
|
+
const a = flags(args.slice(1));
|
|
27
|
+
const config = loadConfig(process.cwd(), a.config || null);
|
|
28
|
+
|
|
29
|
+
if (sub === 'config') {
|
|
30
|
+
console.log(JSON.stringify(config, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (sub === 'log') {
|
|
34
|
+
const home = process.env.TR_NOTIFY_HOME || path.join(process.env.HOME || '', '.total-recall', 'notifications');
|
|
35
|
+
const file = path.join(home, 'sent.jsonl');
|
|
36
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : [];
|
|
37
|
+
console.log(lines.slice(-Number(a.limit || 20)).join('\n'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'send' || sub === 'test') {
|
|
41
|
+
const test = sub === 'test';
|
|
42
|
+
if (!test && (!a.title || !a.message)) { console.error(USAGE); process.exitCode = 2; return; }
|
|
43
|
+
const channels = (a.channels || (test ? Object.keys(config).join(',') : 'os')).split(',').filter(Boolean);
|
|
44
|
+
const results = await sendNotification({
|
|
45
|
+
title: a.title || 'Notification test', message: a.message || 'Dry run of every configured channel.',
|
|
46
|
+
channels, eventId: a['event-id'], severity: a.severity, source: a.source,
|
|
47
|
+
dry: test || a.dry, quietHours: a['quiet-hours'], config,
|
|
48
|
+
});
|
|
49
|
+
console.log(JSON.stringify(results, null, 2));
|
|
50
|
+
if (results.some((r) => r.status === 'failed')) process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log(USAGE);
|
|
54
|
+
if (sub !== 'help') process.exitCode = 2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default run;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify-core: send an alert to the operator over one or more channels.
|
|
3
|
+
*
|
|
4
|
+
* Generic: no recipients, senders or key names are hardcoded. Everything comes
|
|
5
|
+
* from flags, a notify.json config, or the Total Recall secret store.
|
|
6
|
+
* See README.md.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
const HOME_DIR = process.env.TR_NOTIFY_HOME || path.join(os.homedir(), '.total-recall', 'notifications');
|
|
16
|
+
const LEDGER = path.join(HOME_DIR, 'sent.jsonl');
|
|
17
|
+
const LOG = path.join(HOME_DIR, 'notification-log.md');
|
|
18
|
+
|
|
19
|
+
function readJson(p) {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadConfig(cwd = process.cwd(), explicit = null) {
|
|
24
|
+
const layers = [
|
|
25
|
+
readJson(path.join(HOME_DIR, 'config.json')),
|
|
26
|
+
readJson(path.join(cwd, '.agent', 'notify.json')),
|
|
27
|
+
explicit ? readJson(explicit) : {},
|
|
28
|
+
];
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const layer of layers) {
|
|
31
|
+
for (const [k, v] of Object.entries(layer)) out[k] = { ...(out[k] || {}), ...v };
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function secret(name, cwd) {
|
|
37
|
+
if (!name) return null;
|
|
38
|
+
if (process.env[name]) return process.env[name];
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await run('total-recall', ['secret', 'get', name], { cwd, maxBuffer: 1 << 20 });
|
|
41
|
+
return stdout.trim() || null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A recipient is a literal (contains @ or starts with +) or `secret:NAME`. */
|
|
48
|
+
async function recipient(spec, cwd) {
|
|
49
|
+
if (!spec) return null;
|
|
50
|
+
if (spec.startsWith('secret:')) return secret(spec.slice(7), cwd);
|
|
51
|
+
return spec;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function need(cfg, field, cwd, channel) {
|
|
55
|
+
const v = field.endsWith('_secret') ? await secret(cfg[field], cwd) : await recipient(cfg[field], cwd);
|
|
56
|
+
if (!v) throw new Error(`${channel}: missing ${field} (set it in notify.json or via flags)`);
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function post(url, body, headers = {}) {
|
|
61
|
+
const res = await fetch(url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
});
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
if (!res.ok) throw new Error(`${res.status} ${text.slice(0, 200)}`);
|
|
68
|
+
try { return JSON.parse(text); } catch { return { raw: text }; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ADAPTERS = {
|
|
72
|
+
async os({ title, message }, cfg, ctx) {
|
|
73
|
+
if (ctx.dry) return { dry: true };
|
|
74
|
+
try {
|
|
75
|
+
await run('terminal-notifier', ['-title', title, '-message', message, '-open', `file://${LOG}`, '-group', `notify-${ctx.source || 'alert'}`]);
|
|
76
|
+
} catch {
|
|
77
|
+
if (process.platform === 'darwin') {
|
|
78
|
+
const q = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
79
|
+
await run('osascript', ['-e', `display notification "${q(message)}" with title "${q(title)}"`]);
|
|
80
|
+
} else {
|
|
81
|
+
await run('notify-send', [title, message]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { accepted: true };
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// SMTP2GO
|
|
88
|
+
async email({ title, message }, cfg, ctx) {
|
|
89
|
+
const to = await need(cfg, 'to', ctx.cwd, 'email');
|
|
90
|
+
const sender = cfg.from;
|
|
91
|
+
if (!sender) throw new Error('email: missing from (a verified SMTP2GO sender)');
|
|
92
|
+
if (ctx.dry) return { dry: true, to, sender };
|
|
93
|
+
const key = await need(cfg, 'key_secret', ctx.cwd, 'email');
|
|
94
|
+
const r = await post('https://api.smtp2go.com/v3/email/send', {
|
|
95
|
+
to: [to], sender, subject: title, text_body: message,
|
|
96
|
+
}, { 'X-Smtp2go-Api-Key': key });
|
|
97
|
+
if (r?.data?.succeeded < 1) throw new Error(`email: not accepted ${JSON.stringify(r.data)}`);
|
|
98
|
+
return { accepted: true, provider_id: r?.data?.email_id };
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Telnyx Messaging
|
|
102
|
+
async sms({ title, message }, cfg, ctx) {
|
|
103
|
+
const to = await need(cfg, 'to', ctx.cwd, 'sms');
|
|
104
|
+
const from = cfg.from;
|
|
105
|
+
if (!from) throw new Error('sms: missing from (a Telnyx number or messaging profile sender)');
|
|
106
|
+
if (ctx.dry) return { dry: true, to, from };
|
|
107
|
+
const key = await need(cfg, 'key_secret', ctx.cwd, 'sms');
|
|
108
|
+
const r = await post('https://api.telnyx.com/v2/messages', { from, to, text: `${title}\n${message}` }, { Authorization: `Bearer ${key}` });
|
|
109
|
+
return { accepted: true, provider_id: r?.data?.id };
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// Stubs: not wired yet. They report `stub` instead of pretending to send.
|
|
113
|
+
async slack() { return { stub: true, note: 'slack adapter not implemented' }; },
|
|
114
|
+
async discord() { return { stub: true, note: 'discord adapter not implemented' }; },
|
|
115
|
+
async telegram() { return { stub: true, note: 'telegram adapter not implemented' }; },
|
|
116
|
+
async expo() { return { stub: true, note: 'expo adapter not implemented' }; },
|
|
117
|
+
|
|
118
|
+
async webhook({ title, message, eventId, severity, source }, cfg, ctx) {
|
|
119
|
+
if (ctx.dry) return { dry: true };
|
|
120
|
+
const url = await need(cfg, 'url_secret', ctx.cwd, 'webhook');
|
|
121
|
+
await post(url, { title, message, event_id: eventId, severity, source });
|
|
122
|
+
return { accepted: true };
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async github({ title, message }, cfg, ctx) {
|
|
126
|
+
const repo = cfg.repo;
|
|
127
|
+
if (!repo) throw new Error('github: missing repo (owner/name)');
|
|
128
|
+
if (ctx.dry) return { dry: true, repo };
|
|
129
|
+
const token = await need(cfg, 'key_secret', ctx.cwd, 'github');
|
|
130
|
+
const r = await post(`https://api.github.com/repos/${repo}/issues`, { title, body: message, labels: cfg.labels || [] }, {
|
|
131
|
+
Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
|
|
132
|
+
});
|
|
133
|
+
return { accepted: true, provider_id: r?.number };
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function inQuietHours(spec, now = new Date()) {
|
|
138
|
+
const m = /^(\d{1,2})-(\d{1,2})$/.exec(spec || '');
|
|
139
|
+
if (!m) return false;
|
|
140
|
+
const [s, e, h] = [Number(m[1]), Number(m[2]), now.getHours()];
|
|
141
|
+
return s <= e ? h >= s && h < e : h >= s || h < e;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sentBefore(eventId, channel) {
|
|
145
|
+
if (!eventId || !fs.existsSync(LEDGER)) return false;
|
|
146
|
+
return fs.readFileSync(LEDGER, 'utf8').split('\n').some((l) => {
|
|
147
|
+
try { const r = JSON.parse(l); return r.event_id === eventId && r.channel === channel && r.status === 'accepted'; } catch { return false; }
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function record(entry) {
|
|
152
|
+
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
153
|
+
fs.appendFileSync(LEDGER, JSON.stringify(entry) + '\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function logMarkdown(title, message, results, severity, source) {
|
|
157
|
+
try {
|
|
158
|
+
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
159
|
+
const summary = results.map((r) => `${r.channel}:${r.status}`).join(', ');
|
|
160
|
+
const entry = `### [${new Date().toISOString()}] ${title}\n> ${message}\n\n*${[severity, source].filter(Boolean).join(' · ')} | ${summary}*\n\n---\n\n`;
|
|
161
|
+
const old = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8') : '';
|
|
162
|
+
fs.writeFileSync(LOG, entry + old);
|
|
163
|
+
} catch { /* the log never blocks an alert */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function sendNotification(opts) {
|
|
167
|
+
const {
|
|
168
|
+
title, message, channels = ['os'], eventId = null, severity = 'info', source = '',
|
|
169
|
+
dry = false, quietHours = null, cwd = process.cwd(), config = null,
|
|
170
|
+
} = opts;
|
|
171
|
+
const cfg = config || loadConfig(cwd);
|
|
172
|
+
const critical = severity === 'critical' || severity === 'high';
|
|
173
|
+
const quiet = !critical && inQuietHours(quietHours);
|
|
174
|
+
const results = [];
|
|
175
|
+
for (const channel of channels) {
|
|
176
|
+
const base = { channel, event_id: eventId, at: new Date().toISOString() };
|
|
177
|
+
if (!ADAPTERS[channel]) { results.push({ ...base, status: 'failed', error: 'unknown channel' }); continue; }
|
|
178
|
+
if (quiet && channel !== 'os') { results.push({ ...base, status: 'suppressed', reason: 'quiet-hours' }); continue; }
|
|
179
|
+
if (!dry && sentBefore(eventId, channel)) { results.push({ ...base, status: 'duplicate' }); continue; }
|
|
180
|
+
try {
|
|
181
|
+
const r = await ADAPTERS[channel]({ title, message, eventId, severity, source }, cfg[channel] || {}, { dry, cwd, source });
|
|
182
|
+
const row = { ...base, status: r.stub ? 'stub' : dry ? 'dry-run' : 'accepted', ...r };
|
|
183
|
+
results.push(row);
|
|
184
|
+
if (!dry && !r.stub) record({ ...row, channel, event_id: eventId });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
const row = { ...base, status: 'failed', error: String(e.message || e) };
|
|
187
|
+
results.push(row);
|
|
188
|
+
if (!dry) record(row);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!dry) logMarkdown(title, message, results, severity, source);
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
3
|
+
"id": "operator-alerts",
|
|
4
|
+
"name": "Operator Alerts",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Send operator alerts over desktop, email (SMTP2GO), SMS (Telnyx), webhook and issue-tracker alerts with dedupe, quiet hours and delivery evidence. Keys come from the secret store; recipients from config.",
|
|
7
|
+
"author": "Total Recall",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"use_cases": ["alerting"],
|
|
10
|
+
"cli": {
|
|
11
|
+
"command": "alerts",
|
|
12
|
+
"handler": "./cli.mjs",
|
|
13
|
+
"subcommands": [
|
|
14
|
+
{ "name": "send", "description": "Send an alert (--dry-run to resolve config without sending)" },
|
|
15
|
+
{ "name": "test", "description": "Dry-run every configured channel" },
|
|
16
|
+
{ "name": "config", "description": "Show the merged notify config" },
|
|
17
|
+
{ "name": "log", "description": "Recent sends from the ledger" }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/cli/command.mjs
CHANGED
|
@@ -170,7 +170,7 @@ function printHelp() {
|
|
|
170
170
|
`);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
function resolveTargetDir(isGlobal) {
|
|
173
|
+
export function resolveTargetDir(isGlobal) {
|
|
174
174
|
if (isGlobal) {
|
|
175
175
|
try {
|
|
176
176
|
const gBrain = resolveBrainLayer('global');
|
|
@@ -235,7 +235,7 @@ function existingTag(file, key) {
|
|
|
235
235
|
* Composable instructions: the surfaces list every command, so any change to
|
|
236
236
|
* the command set recompiles them (project layer, or every project for global).
|
|
237
237
|
*/
|
|
238
|
-
function recompileSurfaces(isGlobal) {
|
|
238
|
+
export function recompileSurfaces(isGlobal) {
|
|
239
239
|
if (process.env.TR_COMMAND_NO_COMPILE === '1' || !process.argv[1]) return;
|
|
240
240
|
try {
|
|
241
241
|
const child = spawn(process.execPath, [process.argv[1], 'compile', isGlobal ? '--global' : '--project'], { detached: true, stdio: 'ignore' });
|
|
@@ -246,6 +246,48 @@ function recompileSurfaces(isGlobal) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Register every `commands` entry of an installed plugin as a composable command.
|
|
251
|
+
* A command that already exists and was generated from this plugin is refreshed;
|
|
252
|
+
* one that exists from anywhere else is left alone and reported.
|
|
253
|
+
*/
|
|
254
|
+
export function syncPluginCommands(pluginDir, { global: isGlobal = false } = {}) {
|
|
255
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(fs.realpathSync(pluginDir), 'plugin.json'), 'utf8'));
|
|
256
|
+
const commandsDir = resolveTargetDir(isGlobal);
|
|
257
|
+
const result = { created: [], skipped: [] };
|
|
258
|
+
for (const entry of manifest.commands || []) {
|
|
259
|
+
const file = path.join(commandsDir, `${entry.name}.mjs`);
|
|
260
|
+
if (fs.existsSync(file)) {
|
|
261
|
+
if (!fs.readFileSync(file, 'utf8').startsWith(`// Generated from plugin ${manifest.id};`)) {
|
|
262
|
+
result.skipped.push({ name: entry.name, reason: 'a different command with this name exists' });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
fs.rmSync(file);
|
|
266
|
+
}
|
|
267
|
+
generatePluginCommand(pluginDir, entry.name, commandsDir);
|
|
268
|
+
result.created.push(entry.name);
|
|
269
|
+
}
|
|
270
|
+
if (result.created.length) recompileSurfaces(isGlobal);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Remove the commands a plugin generated (only files carrying its generated header). */
|
|
275
|
+
export function removePluginCommands(pluginId, { global: isGlobal = false } = {}) {
|
|
276
|
+
const commandsDir = resolveTargetDir(isGlobal);
|
|
277
|
+
const removed = [];
|
|
278
|
+
if (!fs.existsSync(commandsDir)) return removed;
|
|
279
|
+
for (const name of fs.readdirSync(commandsDir)) {
|
|
280
|
+
if (!name.endsWith('.mjs')) continue;
|
|
281
|
+
const file = path.join(commandsDir, name);
|
|
282
|
+
if (fs.readFileSync(file, 'utf8').startsWith(`// Generated from plugin ${pluginId};`)) {
|
|
283
|
+
fs.rmSync(file);
|
|
284
|
+
removed.push(name.replace(/\.mjs$/, ''));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (removed.length) recompileSurfaces(isGlobal);
|
|
288
|
+
return removed;
|
|
289
|
+
}
|
|
290
|
+
|
|
249
291
|
export default async function commandCmd(rawArgs = []) {
|
|
250
292
|
const isGlobal = rawArgs.includes('--global') || rawArgs.includes('-g');
|
|
251
293
|
const asJson = rawArgs.includes('--json');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { installPlugin as install } from '../../core/plugin-store.mjs';
|
|
2
|
+
import { syncPluginCommands } from '../command.mjs';
|
|
2
3
|
|
|
3
4
|
export async function installPlugin(args = []) {
|
|
4
5
|
const isGlobal = args.includes('--global') || args.includes('-g');
|
|
@@ -16,7 +17,11 @@ export async function installPlugin(args = []) {
|
|
|
16
17
|
const from = result.source.kind === 'peer' ? `peer ${result.source.peer_hostname}` : result.source.kind;
|
|
17
18
|
console.log(`\n✅ Installed '${result.plugin.name}' (id: ${result.plugin.id}, v${result.plugin.version}) from ${from}`);
|
|
18
19
|
console.log(` Location: ${result.plugin.dir}`);
|
|
19
|
-
console.log(` sha256: ${result.sha256}
|
|
20
|
+
console.log(` sha256: ${result.sha256}`);
|
|
21
|
+
const cmds = syncPluginCommands(result.plugin.dir, { global: isGlobal });
|
|
22
|
+
if (cmds.created.length) console.log(` Commands: ${cmds.created.map((c) => `total-recall ${c}`).join(', ')}`);
|
|
23
|
+
for (const c of cmds.skipped) console.log(` Skipped command '${c.name}': ${c.reason}`);
|
|
24
|
+
console.log('');
|
|
20
25
|
} catch (err) {
|
|
21
26
|
console.error(`❌ ${err.message}`);
|
|
22
27
|
process.exit(1);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { uninstallPlugin, setPluginShared } from '../../core/plugin-store.mjs';
|
|
2
|
+
import { removePluginCommands } from '../command.mjs';
|
|
2
3
|
import { publicPluginShareUrl } from '../../core/plugin-public.mjs';
|
|
3
4
|
|
|
4
5
|
export async function removePlugin(args = []) {
|
|
@@ -13,7 +14,10 @@ export async function removePlugin(args = []) {
|
|
|
13
14
|
|
|
14
15
|
try {
|
|
15
16
|
const result = await uninstallPlugin(id, { projectRoot: process.cwd(), global: isGlobal ? true : undefined });
|
|
16
|
-
|
|
17
|
+
const removed = removePluginCommands(id, { global: result.scope === 'global' });
|
|
18
|
+
console.log(`\n✅ Removed plugin '${id}' (${result.scope}) from ${result.dir}`);
|
|
19
|
+
if (removed.length) console.log(` Removed commands: ${removed.join(', ')}`);
|
|
20
|
+
console.log('');
|
|
17
21
|
} catch (err) {
|
|
18
22
|
console.error(`❌ ${err.message}`);
|
|
19
23
|
process.exit(1);
|
package/src/core/env-import.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import { setSecret, listSecretsMeta, updateSecretMeta } from './secrets-store.mjs';
|
|
13
|
+
import { providerForKeyName } from './provider-catalog.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @deprecated Kept as empty/export for older callers. Import does NOT use this
|
|
@@ -108,13 +109,9 @@ export function inferProvider(key) {
|
|
|
108
109
|
if (!key) return null;
|
|
109
110
|
const k = String(key);
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Use sync require-free approach: duplicate-free segment heuristic only here for speed.
|
|
115
|
-
} catch {
|
|
116
|
-
/* ignore */
|
|
117
|
-
}
|
|
112
|
+
// A known provider anywhere in the key name wins (JSN_SMTP2GO_API_KEY -> smtp2go).
|
|
113
|
+
const known = providerForKeyName(k);
|
|
114
|
+
if (known) return known.id;
|
|
118
115
|
|
|
119
116
|
// Strip common prefixes that are not the vendor
|
|
120
117
|
let base = k
|