arc402-cli 0.7.0 → 0.7.2

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 (53) hide show
  1. package/dist/commands/backup.d.ts +3 -0
  2. package/dist/commands/backup.d.ts.map +1 -0
  3. package/dist/commands/backup.js +106 -0
  4. package/dist/commands/backup.js.map +1 -0
  5. package/dist/commands/daemon.d.ts.map +1 -1
  6. package/dist/commands/daemon.js +67 -0
  7. package/dist/commands/daemon.js.map +1 -1
  8. package/dist/commands/discover.d.ts.map +1 -1
  9. package/dist/commands/discover.js +60 -15
  10. package/dist/commands/discover.js.map +1 -1
  11. package/dist/commands/wallet.d.ts.map +1 -1
  12. package/dist/commands/wallet.js +136 -52
  13. package/dist/commands/wallet.js.map +1 -1
  14. package/dist/commands/watch.d.ts.map +1 -1
  15. package/dist/commands/watch.js +146 -9
  16. package/dist/commands/watch.js.map +1 -1
  17. package/dist/config.d.ts +8 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +25 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/daemon/config.d.ts +15 -1
  22. package/dist/daemon/config.d.ts.map +1 -1
  23. package/dist/daemon/config.js +32 -0
  24. package/dist/daemon/config.js.map +1 -1
  25. package/dist/daemon/index.d.ts.map +1 -1
  26. package/dist/daemon/index.js +66 -21
  27. package/dist/daemon/index.js.map +1 -1
  28. package/dist/daemon/notify.d.ts +35 -6
  29. package/dist/daemon/notify.d.ts.map +1 -1
  30. package/dist/daemon/notify.js +176 -48
  31. package/dist/daemon/notify.js.map +1 -1
  32. package/dist/endpoint-notify.d.ts +2 -1
  33. package/dist/endpoint-notify.d.ts.map +1 -1
  34. package/dist/endpoint-notify.js +12 -3
  35. package/dist/endpoint-notify.js.map +1 -1
  36. package/dist/index.js +26 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/program.d.ts.map +1 -1
  39. package/dist/program.js +2 -0
  40. package/dist/program.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/commands/backup.ts +117 -0
  43. package/src/commands/daemon.ts +73 -0
  44. package/src/commands/discover.ts +74 -21
  45. package/src/commands/wallet.ts +137 -51
  46. package/src/commands/watch.ts +207 -10
  47. package/src/config.ts +39 -1
  48. package/src/daemon/config.ts +35 -0
  49. package/src/daemon/index.ts +65 -26
  50. package/src/daemon/notify.ts +199 -59
  51. package/src/endpoint-notify.ts +13 -3
  52. package/src/index.ts +26 -0
  53. package/src/program.ts +2 -0
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Telegram notification module for daemon events.
3
- * Uses Telegram Bot API no external dependencies.
2
+ * Multi-channel notification module for daemon events.
3
+ * Supports Telegram, Discord webhooks, generic webhooks, and email (via nodemailer).
4
4
  */
5
5
  import * as https from "https";
6
6
  import * as http from "http";
7
+ import type { DaemonConfig } from "./config";
7
8
 
8
9
  export type NotifyEvent =
9
10
  | "hire_request"
@@ -16,33 +17,36 @@ export type NotifyEvent =
16
17
  | "daemon_started"
17
18
  | "daemon_stopped";
18
19
 
19
- export interface TelegramConfig {
20
- botToken: string;
21
- chatId: string;
20
+ // ─── Channel interface ────────────────────────────────────────────────────────
21
+
22
+ export interface NotificationChannel {
23
+ send(title: string, body: string): Promise<void>;
22
24
  }
23
25
 
24
- function telegramPost(botToken: string, method: string, body: object): Promise<void> {
26
+ // ─── HTTP helper ──────────────────────────────────────────────────────────────
27
+
28
+ function httpPost(url: string, payload: string, extraHeaders: Record<string, string>): Promise<void> {
25
29
  return new Promise((resolve, reject) => {
26
- const payload = JSON.stringify(body);
27
- const options: https.RequestOptions = {
28
- hostname: "api.telegram.org",
29
- port: 443,
30
- path: `/bot${botToken}/${method}`,
30
+ const parsed = new URL(url);
31
+ const mod = parsed.protocol === "https:" ? https : http;
32
+ const options = {
33
+ hostname: parsed.hostname,
34
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
35
+ path: parsed.pathname + parsed.search,
31
36
  method: "POST",
32
37
  headers: {
33
38
  "Content-Type": "application/json",
34
39
  "Content-Length": Buffer.byteLength(payload),
40
+ ...extraHeaders,
35
41
  },
36
42
  };
37
- const req = https.request(options, (res) => {
38
- let data = "";
39
- res.on("data", (c: Buffer) => { data += c.toString(); });
43
+ const req = mod.request(options, (res) => {
44
+ res.resume();
40
45
  res.on("end", () => {
41
- const parsed = JSON.parse(data) as { ok: boolean; description?: string };
42
- if (!parsed.ok) {
43
- reject(new Error(`Telegram API error: ${parsed.description}`));
44
- } else {
46
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
45
47
  resolve();
48
+ } else {
49
+ reject(new Error(`HTTP ${res.statusCode}`));
46
50
  }
47
51
  });
48
52
  });
@@ -52,16 +56,118 @@ function telegramPost(botToken: string, method: string, body: object): Promise<v
52
56
  });
53
57
  }
54
58
 
59
+ // ─── Telegram channel ─────────────────────────────────────────────────────────
60
+
61
+ export class TelegramChannel implements NotificationChannel {
62
+ constructor(private botToken: string, private chatId: string) {}
63
+
64
+ async send(title: string, body: string): Promise<void> {
65
+ const text = body ? `<b>${title}</b>\n${body}` : `<b>${title}</b>`;
66
+ const payload = JSON.stringify({ chat_id: this.chatId, text, parse_mode: "HTML" });
67
+ const options: https.RequestOptions = {
68
+ hostname: "api.telegram.org",
69
+ port: 443,
70
+ path: `/bot${this.botToken}/sendMessage`,
71
+ method: "POST",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ "Content-Length": Buffer.byteLength(payload),
75
+ },
76
+ };
77
+ await new Promise<void>((resolve, reject) => {
78
+ const req = https.request(options, (res) => {
79
+ let data = "";
80
+ res.on("data", (c: Buffer) => { data += c.toString(); });
81
+ res.on("end", () => {
82
+ const parsed = JSON.parse(data) as { ok: boolean; description?: string };
83
+ if (!parsed.ok) {
84
+ reject(new Error(`Telegram API error: ${parsed.description}`));
85
+ } else {
86
+ resolve();
87
+ }
88
+ });
89
+ });
90
+ req.on("error", reject);
91
+ req.write(payload);
92
+ req.end();
93
+ });
94
+ }
95
+ }
96
+
97
+ // ─── Discord channel ──────────────────────────────────────────────────────────
98
+
99
+ export class DiscordChannel implements NotificationChannel {
100
+ constructor(private webhookUrl: string) {}
101
+
102
+ async send(title: string, body: string): Promise<void> {
103
+ const content = body ? `**${title}**\n${body}` : `**${title}**`;
104
+ await httpPost(this.webhookUrl, JSON.stringify({ content }), {});
105
+ }
106
+ }
107
+
108
+ // ─── Generic webhook channel ──────────────────────────────────────────────────
109
+
110
+ export class WebhookChannel implements NotificationChannel {
111
+ constructor(private url: string, private headers: Record<string, string> = {}) {}
112
+
113
+ async send(title: string, body: string): Promise<void> {
114
+ await httpPost(
115
+ this.url,
116
+ JSON.stringify({ title, body, timestamp: new Date().toISOString() }),
117
+ this.headers
118
+ );
119
+ }
120
+ }
121
+
122
+ // ─── Email channel (optional — requires nodemailer) ───────────────────────────
123
+
124
+ export class EmailChannel implements NotificationChannel {
125
+ constructor(private cfg: {
126
+ smtpHost: string;
127
+ smtpPort: number;
128
+ smtpUser: string;
129
+ smtpPass: string;
130
+ to: string;
131
+ }) {}
132
+
133
+ async send(title: string, body: string): Promise<void> {
134
+ // nodemailer is an optional runtime dependency — load via require to skip
135
+ // compile-time module resolution. Throws a clear message if not installed.
136
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
137
+ let nodemailer: any;
138
+ try {
139
+ // Using Function constructor avoids TypeScript static import analysis.
140
+ nodemailer = (new Function("require", "return require('nodemailer')"))(require);
141
+ } catch {
142
+ throw new Error("nodemailer is not installed. Run: npm install nodemailer");
143
+ }
144
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
145
+ const transport = nodemailer.createTransport({
146
+ host: this.cfg.smtpHost,
147
+ port: this.cfg.smtpPort,
148
+ auth: { user: this.cfg.smtpUser, pass: this.cfg.smtpPass },
149
+ });
150
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
151
+ await transport.sendMail({
152
+ from: this.cfg.smtpUser,
153
+ to: this.cfg.to,
154
+ subject: title,
155
+ text: body,
156
+ });
157
+ }
158
+ }
159
+
160
+ // ─── Notifier ─────────────────────────────────────────────────────────────────
161
+
55
162
  export class Notifier {
56
- private config: TelegramConfig | null;
163
+ private channels: NotificationChannel[];
57
164
  private notifyFlags: Record<NotifyEvent, boolean>;
58
165
 
59
166
  constructor(
60
- botToken: string,
61
- chatId: string,
167
+ channels: NotificationChannel[],
62
168
  flags: Partial<Record<NotifyEvent, boolean>> = {}
63
169
  ) {
64
- this.config = botToken && chatId ? { botToken, chatId } : null;
170
+ this.channels = channels;
65
171
  this.notifyFlags = {
66
172
  hire_request: flags.hire_request ?? true,
67
173
  hire_accepted: flags.hire_accepted ?? true,
@@ -76,82 +182,116 @@ export class Notifier {
76
182
  }
77
183
 
78
184
  isEnabled(): boolean {
79
- return this.config !== null;
185
+ return this.channels.length > 0;
80
186
  }
81
187
 
82
- async send(event: NotifyEvent, message: string): Promise<void> {
83
- if (!this.config) return;
188
+ async send(event: NotifyEvent, title: string, body: string): Promise<void> {
84
189
  if (!this.notifyFlags[event]) return;
85
- try {
86
- await telegramPost(this.config.botToken, "sendMessage", {
87
- chat_id: this.config.chatId,
88
- text: message,
89
- parse_mode: "HTML",
90
- });
91
- } catch (err) {
92
- // Non-fatal — log and continue
93
- process.stderr.write(`[notify] Telegram send failed: ${err}\n`);
94
- }
190
+ await Promise.all(
191
+ this.channels.map(async (ch) => {
192
+ try {
193
+ await ch.send(title, body);
194
+ } catch (err) {
195
+ process.stderr.write(`[notify] Channel send failed: ${err}\n`);
196
+ }
197
+ })
198
+ );
95
199
  }
96
200
 
97
201
  async notifyHireRequest(hireId: string, hirerAddress: string, priceEth: string, capability: string): Promise<void> {
98
202
  const short = hirerAddress.slice(0, 10);
99
- const msg = [
100
- `⚡ <b>Hire Request</b>`,
101
- `ID: <code>${hireId}</code>`,
102
- `From: <code>${short}...</code>`,
203
+ await this.send("hire_request", "Hire Request", [
204
+ `ID: ${hireId}`,
205
+ `From: ${short}...`,
103
206
  `Capability: ${capability || "unspecified"}`,
104
207
  `Price: ${priceEth} ETH`,
105
208
  ``,
106
- `Approve: <code>arc402 daemon approve ${hireId}</code>`,
107
- `Reject: <code>arc402 daemon reject ${hireId}</code>`,
108
- ].join("\n");
109
- await this.send("hire_request", msg);
209
+ `Approve: arc402 daemon approve ${hireId}`,
210
+ `Reject: arc402 daemon reject ${hireId}`,
211
+ ].join("\n"));
110
212
  }
111
213
 
112
214
  async notifyHireAccepted(hireId: string, agreementId: string): Promise<void> {
113
- await this.send("hire_accepted",
114
- `✅ <b>Hire Accepted</b>\nID: <code>${hireId}</code>\nAgreement: <code>${agreementId}</code>`
215
+ await this.send("hire_accepted", "Hire Accepted",
216
+ `ID: ${hireId}\nAgreement: ${agreementId}`
115
217
  );
116
218
  }
117
219
 
118
220
  async notifyHireRejected(hireId: string, reason: string): Promise<void> {
119
- await this.send("hire_rejected",
120
- `❌ <b>Hire Rejected</b>\nID: <code>${hireId}</code>\nReason: ${reason}`
221
+ await this.send("hire_rejected", "Hire Rejected",
222
+ `ID: ${hireId}\nReason: ${reason}`
121
223
  );
122
224
  }
123
225
 
124
226
  async notifyDelivery(agreementId: string, deliveryHash: string, userOpHash: string): Promise<void> {
125
- await this.send("delivery",
126
- `📦 <b>Delivery Submitted</b>\nAgreement: <code>${agreementId}</code>\nDelivery hash: <code>${deliveryHash.slice(0, 16)}...</code>\nUserOp: <code>${userOpHash.slice(0, 16)}...</code>`
127
- );
227
+ await this.send("delivery", "Delivery Submitted", [
228
+ `Agreement: ${agreementId}`,
229
+ `Delivery hash: ${deliveryHash.slice(0, 16)}...`,
230
+ `UserOp: ${userOpHash.slice(0, 16)}...`,
231
+ ].join("\n"));
128
232
  }
129
233
 
130
234
  async notifyDispute(agreementId: string, raisedBy: string): Promise<void> {
131
- await this.send("dispute",
132
- `⚠️ <b>Dispute Raised</b>\nAgreement: <code>${agreementId}</code>\nBy: <code>${raisedBy}</code>`
235
+ await this.send("dispute", "Dispute Raised",
236
+ `Agreement: ${agreementId}\nBy: ${raisedBy}`
133
237
  );
134
238
  }
135
239
 
136
240
  async notifyChannelChallenge(channelId: string, txHash: string): Promise<void> {
137
- await this.send("channel_challenge",
138
- `🔔 <b>Channel Challenged</b>\nChannel: <code>${channelId.slice(0, 16)}...</code>\nTx: <code>${txHash.slice(0, 16)}...</code>`
241
+ await this.send("channel_challenge", "Channel Challenged",
242
+ `Channel: ${channelId.slice(0, 16)}...\nTx: ${txHash.slice(0, 16)}...`
139
243
  );
140
244
  }
141
245
 
142
246
  async notifyLowBalance(balanceEth: string, thresholdEth: string): Promise<void> {
143
- await this.send("low_balance",
144
- `💸 <b>Low Balance Alert</b>\nCurrent: ${balanceEth} ETH\nThreshold: ${thresholdEth} ETH`
247
+ await this.send("low_balance", "Low Balance Alert",
248
+ `Current: ${balanceEth} ETH\nThreshold: ${thresholdEth} ETH`
145
249
  );
146
250
  }
147
251
 
148
252
  async notifyStarted(walletAddress: string, subsystems: string[]): Promise<void> {
149
- await this.send("daemon_started",
150
- `🟢 <b>ARC-402 Daemon Started</b>\nWallet: <code>${walletAddress}</code>\nSubsystems: ${subsystems.join(", ")}`
253
+ await this.send("daemon_started", "ARC-402 Daemon Started",
254
+ `Wallet: ${walletAddress}\nSubsystems: ${subsystems.join(", ")}`
151
255
  );
152
256
  }
153
257
 
154
258
  async notifyStopped(): Promise<void> {
155
- await this.send("daemon_stopped", `🔴 <b>ARC-402 Daemon Stopped</b>`);
259
+ await this.send("daemon_stopped", "ARC-402 Daemon Stopped", "");
156
260
  }
157
261
  }
262
+
263
+ // ─── Factory ──────────────────────────────────────────────────────────────────
264
+
265
+ export function buildNotifier(config: DaemonConfig): Notifier {
266
+ const notif = config.notifications;
267
+ const channels: NotificationChannel[] = [];
268
+
269
+ if (notif.telegram_bot_token && notif.telegram_chat_id) {
270
+ channels.push(new TelegramChannel(notif.telegram_bot_token, notif.telegram_chat_id));
271
+ }
272
+ if (notif.discord?.webhook_url) {
273
+ channels.push(new DiscordChannel(notif.discord.webhook_url));
274
+ }
275
+ if (notif.webhook?.url) {
276
+ channels.push(new WebhookChannel(notif.webhook.url, notif.webhook.headers ?? {}));
277
+ }
278
+ if (notif.email?.smtp_host && notif.email?.smtp_user && notif.email?.to) {
279
+ channels.push(new EmailChannel({
280
+ smtpHost: notif.email.smtp_host,
281
+ smtpPort: notif.email.smtp_port,
282
+ smtpUser: notif.email.smtp_user,
283
+ smtpPass: notif.email.smtp_pass,
284
+ to: notif.email.to,
285
+ }));
286
+ }
287
+
288
+ return new Notifier(channels, {
289
+ hire_request: notif.notify_on_hire_request,
290
+ hire_accepted: notif.notify_on_hire_accepted,
291
+ hire_rejected: notif.notify_on_hire_rejected,
292
+ delivery: notif.notify_on_delivery,
293
+ dispute: notif.notify_on_dispute,
294
+ channel_challenge: notif.notify_on_channel_challenge,
295
+ low_balance: notif.notify_on_low_balance,
296
+ });
297
+ }
@@ -92,11 +92,13 @@ export async function resolveAgentEndpoint(
92
92
  * POSTs JSON payload to {endpoint}{path}. Returns true on success.
93
93
  * Never throws — logs a warning on failure.
94
94
  * Validates endpoint URL for SSRF before connecting.
95
+ * If signingKey is provided, signs the payload and adds X-ARC402-Signature / X-ARC402-Signer headers.
95
96
  */
96
97
  export async function notifyAgent(
97
98
  endpoint: string,
98
99
  path: string,
99
- payload: Record<string, unknown>
100
+ payload: Record<string, unknown>,
101
+ signingKey?: string
100
102
  ): Promise<boolean> {
101
103
  if (!endpoint) return false;
102
104
  try {
@@ -106,10 +108,18 @@ export async function notifyAgent(
106
108
  return false;
107
109
  }
108
110
  try {
111
+ const body = JSON.stringify(payload);
112
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
113
+ if (signingKey) {
114
+ const wallet = new ethers.Wallet(signingKey);
115
+ const signature = await wallet.signMessage(body);
116
+ headers["X-ARC402-Signature"] = signature;
117
+ headers["X-ARC402-Signer"] = wallet.address;
118
+ }
109
119
  const res = await fetch(`${endpoint}${path}`, {
110
120
  method: "POST",
111
- headers: { "Content-Type": "application/json" },
112
- body: JSON.stringify(payload),
121
+ headers,
122
+ body,
113
123
  });
114
124
  return res.ok;
115
125
  } catch (err) {
package/src/index.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { createProgram } from "./program";
3
3
  import { startREPL } from "./repl";
4
+ import { configExists, loadConfig, saveConfig } from "./config";
5
+
6
+ // ── Upgrade safety check ────────────────────────────────────────────────────
7
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
8
+ const currentVersion: string = (require("../package.json") as { version: string }).version;
9
+
10
+ function checkUpgrade(): void {
11
+ if (!configExists()) return;
12
+ try {
13
+ const config = loadConfig();
14
+ const prev = config.lastCliVersion;
15
+ if (prev && prev !== currentVersion) {
16
+ // Compare semver loosely — just print if different
17
+ console.log(`◈ Upgraded from ${prev} → ${currentVersion}`);
18
+ }
19
+ if (config.lastCliVersion !== currentVersion) {
20
+ // Never overwrite existing fields — only update lastCliVersion
21
+ saveConfig({ ...config, lastCliVersion: currentVersion });
22
+ }
23
+ } catch {
24
+ // Never crash on upgrade check
25
+ }
26
+ }
4
27
 
5
28
  const printMode = process.argv.includes("--print");
6
29
 
@@ -11,6 +34,7 @@ if (printMode) {
11
34
  process.env["NO_COLOR"] = "1";
12
35
  process.env["FORCE_COLOR"] = "0";
13
36
  process.env["ARC402_PRINT"] = "1";
37
+ checkUpgrade();
14
38
  const program = createProgram();
15
39
  void program.parseAsync(process.argv).then(() => process.exit(0)).catch((e: unknown) => {
16
40
  console.error(e instanceof Error ? e.message : String(e));
@@ -18,9 +42,11 @@ if (printMode) {
18
42
  });
19
43
  } else if (process.argv.length <= 2) {
20
44
  // No subcommand — enter interactive REPL
45
+ checkUpgrade();
21
46
  void startREPL();
22
47
  } else {
23
48
  // One-shot mode — arc402 wallet deploy still works as usual
49
+ checkUpgrade();
24
50
  const program = createProgram();
25
51
  program.parse(process.argv);
26
52
  }
package/src/program.ts CHANGED
@@ -32,6 +32,7 @@ import { registerMigrateCommands } from "./commands/migrate";
32
32
  import { registerFeedCommand } from "./commands/feed";
33
33
  import { registerArenaCommands } from "./commands/arena";
34
34
  import { registerWatchCommand } from "./commands/watch";
35
+ import { registerBackupCommand } from "./commands/backup";
35
36
  import reputation from "./commands/reputation.js";
36
37
  import policy from "./commands/policy.js";
37
38
 
@@ -78,6 +79,7 @@ export function createProgram(): Command {
78
79
  registerFeedCommand(program);
79
80
  registerArenaCommands(program);
80
81
  registerWatchCommand(program);
82
+ registerBackupCommand(program);
81
83
  program.addCommand(reputation);
82
84
  program.addCommand(policy);
83
85