@vault77/summon 2.0.1 β†’ 2.1.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.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # summonTheWarlord β€” a VAULT77 πŸ”77 relic
2
2
 
3
- ![Release](https://img.shields.io/github/v/release/monthviewsales/summonTheWarlord)
3
+ ![Release](https://img.shields.io/npm/v/@vault77/summon?label=release)
4
4
  ![Node](https://img.shields.io/badge/node-%3E%3D18.x-brightgreen)
5
5
  ![Platform](https://img.shields.io/badge/platform-macOS-blue)
6
6
 
7
- **Version:** 2.0.1
7
+ **Version:** 2.0.2
8
8
 
9
9
  > _Relic software unearthed from VAULT77.
10
10
  > For trench operators only. macOS‑native. Handle with care._
@@ -22,6 +22,31 @@
22
22
 
23
23
  ---
24
24
 
25
+ ## Before `summon setup`
26
+
27
+ First-time operator? Run `summon man` first for the built-in walkthrough.
28
+
29
+ Have these inputs ready:
30
+
31
+ - SolanaTracker RPC URL assigned to your account (full `https://...` endpoint; `advancedTx=true` can be present or omitted because summon enforces it automatically)
32
+ - Wallet private key in one accepted format: base58 string or JSON byte array string (example: `[12,34,...]`)
33
+
34
+ During `summon setup`, you'll be asked for:
35
+
36
+ - `rpcUrl`
37
+ - `slippage` (`number` or `"auto"`)
38
+ - `priorityFee` (`number` or `"auto"`)
39
+ - `priorityFeeLevel` (`min|low|medium|high|veryHigh`)
40
+ - `txVersion` (`v0` or `legacy`)
41
+ - `showQuoteDetails` (`true`/`false`)
42
+ - `DEBUG_MODE` (`true`/`false`)
43
+ - `notificationsEnabled` (`true`/`false`)
44
+ - `jito.enabled` (`true`/`false`)
45
+ - `jito.tip` (SOL, only when Jito is enabled)
46
+ - Whether to store/replace your private key now (`y/N`) and, if yes, paste the key
47
+
48
+ ---
49
+
25
50
  ## πŸ“‘ Connect with VAULT77
26
51
 
27
52
  - **VAULT77 Community:** https://x.com/i/communities/1962257350309650488
@@ -43,6 +68,8 @@ npm install -g @vault77/summon
43
68
  summon setup
44
69
  ```
45
70
 
71
+ If this is your first time, run `summon man` before setup for the full command walkthrough.
72
+
46
73
  This:
47
74
 
48
75
  - Creates/updates your config (RPC URL, slippage, priority fees, etc.)
@@ -67,6 +94,31 @@ summon sell <TOKEN_MINT> 50%
67
94
 
68
95
  ---
69
96
 
97
+ # πŸ“˜ Command Reference
98
+
99
+ For the full first-time walkthrough:
100
+
101
+ ```bash
102
+ summon man
103
+ ```
104
+
105
+ - `summon setup` β€” interactive setup for config plus Keychain/private key prompts
106
+ - `summon config view` β€” show current config
107
+ - `summon config edit` β€” edit config in your `$EDITOR`
108
+ - `summon config set <key> <value>` β€” set one config value
109
+ - `summon config wizard` β€” interactive, validated config editor
110
+ - `summon config list` β€” list config keys and expected types
111
+ - `summon keychain store` β€” store private key in macOS Keychain
112
+ - `summon keychain unlock` β€” verify key retrieval from Keychain
113
+ - `summon keychain delete` β€” delete stored private key
114
+ - `summon buy <TOKEN_MINT> <amount>` β€” buy with SOL amount (`auto` is not supported for buys)
115
+ - `summon sell <TOKEN_MINT> <amount>` β€” sell fixed amount, percent (like `50%`), or `auto`
116
+ - `summon wallet` β€” open your wallet page in browser
117
+ - `summon doctor` β€” run config/Keychain/RPC/swap/notification diagnostics
118
+ - `summon man` β€” display the built-in manual
119
+
120
+ ---
121
+
70
122
  # 🧰 Local Development (optional)
71
123
 
72
124
  ```bash
@@ -178,15 +230,10 @@ Dependencies:
178
230
  - `commander` β€” [MIT](https://github.com/tj/commander.js/blob/HEAD/LICENSE)
179
231
  - `fs-extra` β€” [MIT](https://github.com/jprichardson/node-fs-extra/blob/HEAD/LICENSE)
180
232
  - `keytar` β€” [MIT](https://github.com/atom/node-keytar/blob/HEAD/LICENSE)
181
- - `npm` β€” [Artistic-2.0](https://github.com/npm/cli/blob/HEAD/LICENSE)
182
233
  - `open` β€” [MIT](https://github.com/sindresorhus/open/blob/HEAD/LICENSE)
183
234
  - `solana-swap` β€” [ISC](https://github.com/YZYLAB/solana-swap/blob/HEAD/LICENSE)
184
235
 
185
236
  Tooling:
186
237
 
187
238
  - `eslint` β€” [MIT](https://github.com/eslint/eslint/blob/HEAD/LICENSE)
188
- - `eslint-config-standard` β€” [MIT](https://github.com/standard/eslint-config-standard/blob/HEAD/LICENSE)
189
- - `eslint-plugin-import` β€” [MIT](https://github.com/import-js/eslint-plugin-import/blob/HEAD/LICENSE)
190
- - `eslint-plugin-n` β€” [MIT](https://github.com/eslint-community/eslint-plugin-n/blob/HEAD/LICENSE)
191
- - `eslint-plugin-promise` β€” [ISC](https://github.com/eslint-community/eslint-plugin-promise/blob/HEAD/LICENSE)
192
239
  - `jest` β€” [MIT](https://github.com/jestjs/jest/blob/HEAD/LICENSE)
package/lib/config.js CHANGED
@@ -239,7 +239,7 @@ export async function loadConfig() {
239
239
  let cfg;
240
240
  try {
241
241
  cfg = await fs.readJson(configPath);
242
- } catch (err) {
242
+ } catch {
243
243
  let backupPath;
244
244
  try {
245
245
  if (await fs.pathExists(configPath)) {
package/lib/doctor.js CHANGED
@@ -7,8 +7,8 @@ const WRAPPED_SOL_MINT = "So11111111111111111111111111111111111111112";
7
7
  const TRUMP_MINT = "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN";
8
8
  const MIN_SWAP_SOL = 0.0001;
9
9
 
10
- function makeResult(name, status, message, details) {
11
- return { name, status, message, details };
10
+ function makeResult(name, status, message, details, hint) {
11
+ return { name, status, message, details, hint };
12
12
  }
13
13
 
14
14
  async function checkConfig() {
@@ -16,7 +16,16 @@ async function checkConfig() {
16
16
  const cfg = await loadConfig();
17
17
  return { cfg, result: makeResult("config", "ok", "Loaded and normalized config.") };
18
18
  } catch (err) {
19
- return { cfg: null, result: makeResult("config", "fail", "Failed to load config.", err?.message) };
19
+ return {
20
+ cfg: null,
21
+ result: makeResult(
22
+ "config",
23
+ "fail",
24
+ "Failed to load config.",
25
+ err?.message,
26
+ "Run `summon setup` or `summon config wizard`."
27
+ ),
28
+ };
20
29
  }
21
30
  }
22
31
 
@@ -24,18 +33,42 @@ async function checkKeychain() {
24
33
  try {
25
34
  const exists = await hasPrivateKey();
26
35
  if (!exists) {
27
- return { ok: false, result: makeResult("keychain", "fail", "No private key stored.") };
36
+ return {
37
+ ok: false,
38
+ result: makeResult(
39
+ "keychain",
40
+ "fail",
41
+ "No private key stored.",
42
+ undefined,
43
+ "Run `summon keychain store`."
44
+ ),
45
+ };
28
46
  }
29
47
  await getPrivateKey();
30
48
  return { ok: true, result: makeResult("keychain", "ok", "Private key accessible.") };
31
49
  } catch (err) {
32
- return { ok: false, result: makeResult("keychain", "fail", "Unable to read private key.", err?.message) };
50
+ return {
51
+ ok: false,
52
+ result: makeResult(
53
+ "keychain",
54
+ "fail",
55
+ "Unable to read private key.",
56
+ err?.message,
57
+ "Run `summon keychain store`."
58
+ ),
59
+ };
33
60
  }
34
61
  }
35
62
 
36
63
  async function checkRpc(rpcUrl) {
37
64
  if (!rpcUrl) {
38
- return makeResult("rpc", "fail", "RPC URL not configured.");
65
+ return makeResult(
66
+ "rpc",
67
+ "fail",
68
+ "RPC URL not configured.",
69
+ undefined,
70
+ "Update `rpcUrl` via `summon config wizard`."
71
+ );
39
72
  }
40
73
  const healthUrl = ensureAdvancedTx(rpcUrl);
41
74
  try {
@@ -45,15 +78,33 @@ async function checkRpc(rpcUrl) {
45
78
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
46
79
  });
47
80
  if (!res.ok) {
48
- return makeResult("rpc", "fail", "RPC health check failed.", `HTTP ${res.status}`);
81
+ return makeResult(
82
+ "rpc",
83
+ "fail",
84
+ "RPC health check failed.",
85
+ `HTTP ${res.status}`,
86
+ "Update `rpcUrl` via `summon config wizard`."
87
+ );
49
88
  }
50
89
  const body = await res.json();
51
90
  if (body.result !== "ok") {
52
- return makeResult("rpc", "fail", "RPC returned unhealthy status.", JSON.stringify(body));
91
+ return makeResult(
92
+ "rpc",
93
+ "fail",
94
+ "RPC returned unhealthy status.",
95
+ JSON.stringify(body),
96
+ "Update `rpcUrl` via `summon config wizard`."
97
+ );
53
98
  }
54
99
  return makeResult("rpc", "ok", "RPC reachable.");
55
100
  } catch (err) {
56
- return makeResult("rpc", "fail", "RPC health check error.", err?.message);
101
+ return makeResult(
102
+ "rpc",
103
+ "fail",
104
+ "RPC health check error.",
105
+ err?.message,
106
+ "Update `rpcUrl` via `summon config wizard`."
107
+ );
57
108
  }
58
109
  }
59
110
 
@@ -84,11 +135,23 @@ async function checkSwapApi(cfg, keychainOk) {
84
135
  );
85
136
  const quote = swapResp?.quote ?? swapResp?.rate;
86
137
  if (!quote) {
87
- return makeResult("swap", "fail", "Swap API response missing quote.");
138
+ return makeResult(
139
+ "swap",
140
+ "fail",
141
+ "Swap API response missing quote.",
142
+ undefined,
143
+ "Rerun `summon doctor -v` and verify SolanaTracker account/RPC."
144
+ );
88
145
  }
89
146
  return makeResult("swap", "ok", "Swap API reachable.");
90
147
  } catch (err) {
91
- return makeResult("swap", "fail", "Swap API check failed.", err?.message);
148
+ return makeResult(
149
+ "swap",
150
+ "fail",
151
+ "Swap API check failed.",
152
+ err?.message,
153
+ "Rerun `summon doctor -v` and verify SolanaTracker account/RPC."
154
+ );
92
155
  }
93
156
  }
94
157
 
@@ -108,11 +171,23 @@ async function checkNotifications(cfg) {
108
171
  throwOnError: true,
109
172
  });
110
173
  if (!ok) {
111
- return makeResult("notifications", "fail", "Notification failed.");
174
+ return makeResult(
175
+ "notifications",
176
+ "fail",
177
+ "Notification failed.",
178
+ undefined,
179
+ "Enable terminal notifications or disable `notificationsEnabled`."
180
+ );
112
181
  }
113
182
  return makeResult("notifications", "ok", "Notification sent.");
114
183
  } catch (err) {
115
- return makeResult("notifications", "fail", "Notification failed.", err?.message);
184
+ return makeResult(
185
+ "notifications",
186
+ "fail",
187
+ "Notification failed.",
188
+ err?.message,
189
+ "Enable terminal notifications or disable `notificationsEnabled`."
190
+ );
116
191
  }
117
192
  }
118
193
 
@@ -137,6 +212,7 @@ export async function runDoctor({ verbose = false } = {}) {
137
212
  return results.map((item) => ({
138
213
  ...item,
139
214
  details: item.details || undefined,
215
+ hint: item.hint || undefined,
140
216
  }));
141
217
  }
142
218
  return results;
package/lib/swapClient.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { SolanaTracker } from "solana-swap";
2
2
  import { loadConfig } from "./config.js";
3
- import { getPrivateKey } from "../utils/keychain.js";
4
3
  import { logger } from "../utils/logger.js";
5
4
  import { ConfigError, SwapError } from "./errors.js";
6
5
  import { notify } from "../utils/notify.js";
@@ -9,6 +8,8 @@ const SWAP_DISCOUNT_CODE = "jduck-d815-4c28-b85d-17e9fc3a21a8";
9
8
 
10
9
  let clientPromise = null;
11
10
  let clientFactory = defaultFactory;
11
+ let memoizedRpcUrl = null;
12
+ let warnedOnConflictingCfg = false;
12
13
 
13
14
  export function ensureAdvancedTx(rpcUrl) {
14
15
  if (!rpcUrl || typeof rpcUrl !== "string") {
@@ -24,20 +25,56 @@ export function ensureAdvancedTx(rpcUrl) {
24
25
  export function setSwapClientFactory(factory) {
25
26
  clientFactory = factory;
26
27
  clientPromise = null;
28
+ memoizedRpcUrl = null;
29
+ warnedOnConflictingCfg = false;
27
30
  }
28
31
 
29
- export async function getSwapClient() {
32
+ function getConfigRpcUrl(cfg) {
33
+ if (!cfg || typeof cfg !== "object" || typeof cfg.rpcUrl !== "string") {
34
+ return null;
35
+ }
36
+ try {
37
+ return ensureAdvancedTx(cfg.rpcUrl);
38
+ } catch {
39
+ // Keep getSwapClient backward-compatible: ignore unusable cfg hints once client is memoized.
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Returns the shared swap client instance.
46
+ * `options.cfg` is only consumed during initial client creation; subsequent calls always reuse
47
+ * the memoized client to preserve existing behavior.
48
+ */
49
+ export async function getSwapClient(options = {}) {
50
+ const requestedRpcUrl = getConfigRpcUrl(options?.cfg);
51
+
30
52
  if (!clientPromise) {
31
- clientPromise = clientFactory().catch((err) => {
53
+ memoizedRpcUrl = requestedRpcUrl;
54
+ clientPromise = clientFactory(options).catch((err) => {
32
55
  clientPromise = null;
56
+ memoizedRpcUrl = null;
57
+ warnedOnConflictingCfg = false;
33
58
  throw err;
34
59
  });
60
+ } else if (
61
+ requestedRpcUrl
62
+ && memoizedRpcUrl
63
+ && requestedRpcUrl !== memoizedRpcUrl
64
+ && !warnedOnConflictingCfg
65
+ ) {
66
+ warnedOnConflictingCfg = true;
67
+ logger.warn(
68
+ "getSwapClient received cfg.rpcUrl that differs from the memoized client; reusing existing client."
69
+ );
35
70
  }
71
+
36
72
  return clientPromise;
37
73
  }
38
74
 
39
- async function defaultFactory() {
40
- const cfg = await loadConfig();
75
+ async function defaultFactory(options = {}) {
76
+ const cfg = options.cfg ?? await loadConfig();
77
+ const { getPrivateKey } = await import("../utils/keychain.js");
41
78
  let raw;
42
79
  try {
43
80
  raw = await getPrivateKey();
@@ -0,0 +1,104 @@
1
+ export const MINT_EXAMPLE = "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN";
2
+
3
+ const MINT_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
4
+ const FIELD_ORDER = ["mint", "amount"];
5
+
6
+ const AMOUNT_EXAMPLES = Object.freeze({
7
+ buy: ["0.01", "25%"],
8
+ sell: ["100", "25%", "auto"],
9
+ });
10
+
11
+ export function getAmountExamples(type) {
12
+ return AMOUNT_EXAMPLES[type] || ["0.01"];
13
+ }
14
+
15
+ export function normalizeMint(rawMint) {
16
+ return String(rawMint ?? "").trim();
17
+ }
18
+
19
+ export function validateMint(rawMint) {
20
+ const mint = normalizeMint(rawMint);
21
+ if (!mint) {
22
+ return {
23
+ ok: false,
24
+ field: "mint",
25
+ code: "missing",
26
+ message: "Missing mint address. Provide a base58 token mint (32-44 chars).",
27
+ value: null,
28
+ };
29
+ }
30
+ if (!MINT_REGEX.test(mint)) {
31
+ return {
32
+ ok: false,
33
+ field: "mint",
34
+ code: "invalid",
35
+ message: "Invalid mint format. Expected base58 address (32-44 chars).",
36
+ value: null,
37
+ };
38
+ }
39
+ return { ok: true, field: "mint", code: null, message: null, value: mint };
40
+ }
41
+
42
+ export function normalizeAmount(rawAmount) {
43
+ return String(rawAmount ?? "")
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/\s+/g, "");
47
+ }
48
+
49
+ export function parseAmount(rawAmount) {
50
+ const normalized = normalizeAmount(rawAmount);
51
+ if (!normalized) {
52
+ return {
53
+ ok: false,
54
+ field: "amount",
55
+ code: "missing",
56
+ message: "Missing amount.",
57
+ value: null,
58
+ };
59
+ }
60
+ if (normalized !== "auto" && !normalized.endsWith("%")) {
61
+ const parsed = parseFloat(normalized);
62
+ if (Number.isNaN(parsed) || parsed <= 0) {
63
+ return {
64
+ ok: false,
65
+ field: "amount",
66
+ code: "invalid",
67
+ message: "Invalid amount. Use a positive number, 'auto' during a sell, or '<percent>%'.",
68
+ value: null,
69
+ };
70
+ }
71
+ return { ok: true, field: "amount", code: null, message: null, value: parsed };
72
+ }
73
+ return { ok: true, field: "amount", code: null, message: null, value: normalized };
74
+ }
75
+
76
+ export function validateTradeInput({ type, mint, amount }) {
77
+ const mintResult = validateMint(mint);
78
+ const amountResult = parseAmount(amount);
79
+ const issues = [];
80
+
81
+ if (!mintResult.ok) {
82
+ issues.push(mintResult);
83
+ }
84
+ if (!amountResult.ok) {
85
+ issues.push(amountResult);
86
+ } else if (type === "buy" && amountResult.value === "auto") {
87
+ issues.push({
88
+ ok: false,
89
+ field: "amount",
90
+ code: "buy_auto_not_supported",
91
+ message: "Buying with 'auto' isn't supported. Use a number or '<percent>%'.",
92
+ value: null,
93
+ });
94
+ }
95
+
96
+ issues.sort((left, right) => FIELD_ORDER.indexOf(left.field) - FIELD_ORDER.indexOf(right.field));
97
+
98
+ return {
99
+ ok: issues.length === 0,
100
+ mint: mintResult.ok ? mintResult.value : null,
101
+ amount: amountResult.ok ? amountResult.value : null,
102
+ issues,
103
+ };
104
+ }
package/lib/trades.js CHANGED
@@ -5,25 +5,185 @@ import { getSwapClient } from "./swapClient.js";
5
5
 
6
6
  // Wrapped SOL mint address on Solana
7
7
  const WRAPPED_SOL_MINT = "So11111111111111111111111111111111111111112";
8
- const VERIFY_ATTEMPTS = 20;
9
- const VERIFY_DELAY_MS = 1000;
8
+ const VERIFY_DELAY_SCHEDULE_MS = [500, 1000, 2000, 3000, 4000, 5000];
9
+ const CONFIRMED_TRANSACTION_STATUSES = new Set([
10
+ "processed",
11
+ "confirmed",
12
+ "finalized",
13
+ "success",
14
+ "succeeded",
15
+ "ok",
16
+ ]);
17
+ const FAILED_TRANSACTION_STATUSES = new Set([
18
+ "failed",
19
+ "failure",
20
+ "error",
21
+ "errored",
22
+ "reverted",
23
+ "dropped",
24
+ ]);
10
25
 
11
26
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+ const hasOwn = (value, key) => Boolean(value) && Object.prototype.hasOwnProperty.call(value, key);
28
+
29
+ function formatTransactionError(err) {
30
+ if (typeof err === "string") {
31
+ return err;
32
+ }
33
+ try {
34
+ return JSON.stringify(err);
35
+ } catch {
36
+ return String(err);
37
+ }
38
+ }
39
+
40
+ function classifyTransactionStatus(status) {
41
+ if (status == null) {
42
+ return "unknown";
43
+ }
44
+
45
+ if (typeof status === "string") {
46
+ const normalized = status.trim().toLowerCase();
47
+ if (CONFIRMED_TRANSACTION_STATUSES.has(normalized)) {
48
+ return "confirmed";
49
+ }
50
+ if (FAILED_TRANSACTION_STATUSES.has(normalized)) {
51
+ return "failed";
52
+ }
53
+ return "unknown";
54
+ }
55
+
56
+ if (typeof status === "object") {
57
+ if ((hasOwn(status, "Err") && status.Err != null)
58
+ || (hasOwn(status, "err") && status.err != null)
59
+ || (hasOwn(status, "error") && status.error != null)) {
60
+ return "failed";
61
+ }
62
+ if (hasOwn(status, "Ok")) {
63
+ return "confirmed";
64
+ }
65
+ if (hasOwn(status, "confirmationStatus")) {
66
+ return classifyTransactionStatus(status.confirmationStatus);
67
+ }
68
+ if (hasOwn(status, "status")) {
69
+ return classifyTransactionStatus(status.status);
70
+ }
71
+ }
72
+
73
+ return "unknown";
74
+ }
75
+
76
+ function extractStatusError(status) {
77
+ if (status == null) {
78
+ return "unknown transaction status";
79
+ }
80
+ if (typeof status === "string") {
81
+ return status;
82
+ }
83
+ if (typeof status === "object") {
84
+ return status.Err ?? status.err ?? status.error ?? status;
85
+ }
86
+ return status;
87
+ }
88
+
89
+ function getVerificationState(details) {
90
+ if (!details) {
91
+ return "pending";
92
+ }
93
+
94
+ const hasMetaErr = hasOwn(details.meta, "err");
95
+ if (hasMetaErr && details.meta.err != null) {
96
+ throw new Error(`Transaction failed: ${formatTransactionError(details.meta.err)}`);
97
+ }
98
+ if (hasMetaErr && details.meta.err === null) {
99
+ return "confirmed";
100
+ }
101
+
102
+ const directErrors = [details.err, details.error, details.value?.err, details.value?.error];
103
+ for (const err of directErrors) {
104
+ if (err != null) {
105
+ throw new Error(`Transaction failed: ${formatTransactionError(err)}`);
106
+ }
107
+ }
108
+
109
+ const statusCandidates = [
110
+ details.confirmationStatus,
111
+ details.status,
112
+ details.meta?.status,
113
+ details.value?.confirmationStatus,
114
+ details.value?.status,
115
+ ];
116
+ const hasStatusHint = statusCandidates.some((status) => status != null);
117
+
118
+ for (const status of statusCandidates) {
119
+ const state = classifyTransactionStatus(status);
120
+ if (state === "confirmed") {
121
+ return "confirmed";
122
+ }
123
+ if (state === "failed") {
124
+ throw new Error(`Transaction failed: ${formatTransactionError(extractStatusError(status))}`);
125
+ }
126
+ }
127
+
128
+ if (!hasStatusHint && details.meta && typeof details.meta === "object") {
129
+ return "confirmed";
130
+ }
131
+
132
+ return "pending";
133
+ }
134
+
135
+ function isTransientTransactionDetailsError(err) {
136
+ const status = err?.status ?? err?.statusCode ?? err?.response?.status;
137
+ if (typeof status === "number" && [408, 425, 429, 500, 502, 503, 504].includes(status)) {
138
+ return true;
139
+ }
140
+
141
+ const code = String(err?.code ?? "").toUpperCase();
142
+ if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EAI_AGAIN"].includes(code)) {
143
+ return true;
144
+ }
145
+
146
+ const message = String(err?.message ?? err ?? "").toLowerCase();
147
+ return [
148
+ "timeout",
149
+ "timed out",
150
+ "network",
151
+ "fetch failed",
152
+ "socket hang up",
153
+ "temporarily unavailable",
154
+ "too many requests",
155
+ "rate limit",
156
+ ].some((needle) => message.includes(needle));
157
+ }
12
158
 
13
159
  async function verifySwap(tracker, txid) {
14
- for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) {
15
- const details = await tracker.getTransactionDetails(txid);
16
- if (details) {
17
- if (details.meta?.err) {
18
- const errText = typeof details.meta.err === "string"
19
- ? details.meta.err
20
- : JSON.stringify(details.meta.err);
21
- throw new Error(`Transaction failed: ${errText}`);
160
+ const checkDetails = async () => {
161
+ let details;
162
+ try {
163
+ details = await tracker.getTransactionDetails(txid);
164
+ } catch (err) {
165
+ if (isTransientTransactionDetailsError(err)) {
166
+ return "pending";
22
167
  }
168
+ throw err;
169
+ }
170
+
171
+ return getVerificationState(details);
172
+ };
173
+
174
+ const immediateState = await checkDetails();
175
+ if (immediateState === "confirmed") {
176
+ return true;
177
+ }
178
+
179
+ for (const waitMs of VERIFY_DELAY_SCHEDULE_MS) {
180
+ await sleep(waitMs);
181
+ const state = await checkDetails();
182
+ if (state === "confirmed") {
23
183
  return true;
24
184
  }
25
- await sleep(VERIFY_DELAY_MS);
26
185
  }
186
+
27
187
  return false;
28
188
  }
29
189
 
@@ -31,11 +191,12 @@ async function verifySwap(tracker, txid) {
31
191
  * Buy tokens: spend a specific amount of SOL to acquire <mint>.
32
192
  * @param {string} mint SPL token mint address
33
193
  * @param {number|string} amountSol Amount in SOL to spend, or "auto"/"<percent>%"
194
+ * @param {{ cfg?: object }} [context] Optional context carrying preloaded config
34
195
  * @returns Promise resolving with txid, tokens received, and quote/rate details
35
196
  */
36
- export async function buyToken(mint, amountSol) {
37
- const cfg = await loadConfig();
38
- const tracker = await getSwapClient();
197
+ export async function buyToken(mint, amountSol, context = {}) {
198
+ const cfg = context?.cfg ?? await loadConfig();
199
+ const tracker = await getSwapClient({ cfg });
39
200
  const debugEnabled = Boolean(cfg.DEBUG_MODE || process.env.NODE_ENV === "development");
40
201
  const notificationsEnabled = cfg.notificationsEnabled !== false;
41
202
 
@@ -131,11 +292,12 @@ export async function buyToken(mint, amountSol) {
131
292
  * Sell tokens: swap a specified amount (decimal, 'auto', or '<percent>%') back to SOL.
132
293
  * @param {string} mint SPL token mint address
133
294
  * @param {number|string} amount Decimal amount, "auto", or "<percent>%"
295
+ * @param {{ cfg?: object }} [context] Optional context carrying preloaded config
134
296
  * @returns Promise resolving with txid, SOL received, and quote/rate details
135
297
  */
136
- export async function sellToken(mint, amount) {
137
- const cfg = await loadConfig();
138
- const tracker = await getSwapClient();
298
+ export async function sellToken(mint, amount, context = {}) {
299
+ const cfg = context?.cfg ?? await loadConfig();
300
+ const tracker = await getSwapClient({ cfg });
139
301
  const debugEnabled = Boolean(cfg.DEBUG_MODE || process.env.NODE_ENV === "development");
140
302
  const notificationsEnabled = cfg.notificationsEnabled !== false;
141
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vault77/summon",
3
- "version": "2.0.1",
3
+ "version": "2.1.2",
4
4
  "description": "A recovered VAULT77 relic for macOS operators. summonTheWarlord is a high-performance CLI tool for ultra-fast, low-fee Solana swaps on macOS. Private keys are secured using the native macOS Keychain, never written to disk or exposed to JavaScript memory longer than required. Built for serious CLI workflows with system notifications, local-first execution, and zero browser dependency.",
5
5
  "keywords": [
6
6
  "solana",
@@ -47,21 +47,19 @@
47
47
  "lint:fix": "eslint . --fix"
48
48
  },
49
49
  "devDependencies": {
50
- "eslint": "^8.57.1",
51
- "eslint-config-standard": "^17.1.0",
50
+ "@eslint/js": "^9.39.2",
51
+ "eslint": "^9.39.2",
52
52
  "eslint-plugin-import": "^2.32.0",
53
- "eslint-plugin-n": "^16.6.2",
54
- "eslint-plugin-promise": "^6.6.0",
53
+ "globals": "^17.3.0",
55
54
  "jest": "^30.2.0"
56
55
  },
57
56
  "dependencies": {
58
57
  "@solana/web3.js": "^1.98.4",
59
- "axios": "^1.13.3",
58
+ "axios": "^1.13.5",
60
59
  "bs58": "^6.0.0",
61
- "commander": "^14.0.2",
60
+ "commander": "^14.0.3",
62
61
  "fs-extra": "^11.3.3",
63
62
  "keytar": "^7.9.0",
64
- "npm": "^11.8.0",
65
63
  "open": "^11.0.0",
66
64
  "solana-swap": "1.3.0"
67
65
  }
package/summon-cli.js CHANGED
@@ -16,6 +16,7 @@ import { storePrivateKey, getPrivateKey, deletePrivateKey, hasPrivateKey } from
16
16
  import readline from "readline";
17
17
  import { notify } from "./utils/notify.js";
18
18
  import { runDoctor } from "./lib/doctor.js";
19
+ import { MINT_EXAMPLE, getAmountExamples, validateTradeInput } from "./lib/tradeInput.js";
19
20
 
20
21
  const program = new Command();
21
22
  program
@@ -44,6 +45,59 @@ const CONFIG_HELP = [
44
45
  { key: "jito.enabled", type: "true | false", note: "Enable Jito bundles" },
45
46
  { key: "jito.tip", type: "number", note: "Tip in SOL when Jito enabled" },
46
47
  ];
48
+ const WIZARD_FIELD_GUIDANCE = {
49
+ rpcUrl: {
50
+ helper: "Use your SolanaTracker RPC URL. advancedTx=true is appended automatically.",
51
+ recommended: "Your dedicated SolanaTracker endpoint",
52
+ defaultValue: DEFAULT_CONFIG.rpcUrl,
53
+ },
54
+ slippage: {
55
+ helper: "Max swap slippage percent. Use auto to let the backend choose dynamically.",
56
+ recommended: DEFAULT_CONFIG.slippage,
57
+ defaultValue: DEFAULT_CONFIG.slippage,
58
+ },
59
+ priorityFee: {
60
+ helper:
61
+ "Use auto for adaptive fees, or set a fixed SOL value (example: 0.0005). When set to auto, priorityFeeLevel controls aggressiveness.",
62
+ recommended: `${DEFAULT_CONFIG.priorityFee} + ${DEFAULT_CONFIG.priorityFeeLevel}`,
63
+ defaultValue: DEFAULT_CONFIG.priorityFee,
64
+ },
65
+ priorityFeeLevel: {
66
+ helper: "Used only when priorityFee is auto. Fixed priorityFee values ignore this setting.",
67
+ recommended: DEFAULT_CONFIG.priorityFeeLevel,
68
+ defaultValue: DEFAULT_CONFIG.priorityFeeLevel,
69
+ },
70
+ txVersion: {
71
+ helper: "v0 supports address lookup tables. legacy is only for compatibility edge cases.",
72
+ recommended: DEFAULT_CONFIG.txVersion,
73
+ defaultValue: DEFAULT_CONFIG.txVersion,
74
+ },
75
+ showQuoteDetails: {
76
+ helper: "Print full quote payload after swaps. Enable only when debugging swap math.",
77
+ recommended: DEFAULT_CONFIG.showQuoteDetails,
78
+ defaultValue: DEFAULT_CONFIG.showQuoteDetails,
79
+ },
80
+ DEBUG_MODE: {
81
+ helper: "Verbose SDK/network logging. Useful for diagnostics, noisy for regular trading.",
82
+ recommended: DEFAULT_CONFIG.DEBUG_MODE,
83
+ defaultValue: DEFAULT_CONFIG.DEBUG_MODE,
84
+ },
85
+ notificationsEnabled: {
86
+ helper: "macOS desktop notifications for trade and setup events.",
87
+ recommended: DEFAULT_CONFIG.notificationsEnabled,
88
+ defaultValue: DEFAULT_CONFIG.notificationsEnabled,
89
+ },
90
+ jitoEnabled: {
91
+ helper: "Bundle via Jito relays. Keep disabled unless you intentionally use Jito flow.",
92
+ recommended: DEFAULT_CONFIG.jito.enabled,
93
+ defaultValue: DEFAULT_CONFIG.jito.enabled,
94
+ },
95
+ jitoTip: {
96
+ helper: "Tip in SOL attached to Jito bundles when enabled.",
97
+ recommended: DEFAULT_CONFIG.jito.tip,
98
+ defaultValue: DEFAULT_CONFIG.jito.tip,
99
+ },
100
+ };
47
101
 
48
102
  const askQuestion = (rl, prompt) =>
49
103
  new Promise((resolve) => rl.question(prompt, (answer) => resolve(answer.trim())));
@@ -73,6 +127,16 @@ function renderWizardHeader() {
73
127
  console.log("Press Enter to keep the current value.\n");
74
128
  }
75
129
 
130
+ function renderWizardFieldGuidance(field) {
131
+ const guidance = WIZARD_FIELD_GUIDANCE[field];
132
+ if (!guidance) {
133
+ return;
134
+ }
135
+ console.log(`Help: ${guidance.helper}`);
136
+ console.log(`Recommended: ${toDisplayValue(guidance.recommended)}`);
137
+ console.log(`Default: ${toDisplayValue(guidance.defaultValue)}\n`);
138
+ }
139
+
76
140
  function toDisplayValue(value) {
77
141
  if (value === undefined || value === null) return "";
78
142
  if (typeof value === "object") return JSON.stringify(value);
@@ -229,24 +293,26 @@ async function runConfigWizard({ cfg, rl }) {
229
293
 
230
294
  clearScreen();
231
295
  renderWizardHeader();
232
- console.log("RPC URL should be the SolanaTracker endpoint assigned to you.");
233
- console.log("advancedTx=true is enforced automatically.\n");
296
+ renderWizardFieldGuidance("rpcUrl");
234
297
  nextCfg.rpcUrl = await promptNormalized(rl, "RPC URL", "rpcUrl", { current: nextCfg.rpcUrl });
235
298
 
236
299
  clearScreen();
237
300
  renderWizardHeader();
301
+ renderWizardFieldGuidance("slippage");
238
302
  nextCfg.slippage = await promptNormalized(rl, "Max slippage (number or \"auto\")", "slippage", {
239
303
  current: nextCfg.slippage,
240
304
  });
241
305
 
242
306
  clearScreen();
243
307
  renderWizardHeader();
308
+ renderWizardFieldGuidance("priorityFee");
244
309
  nextCfg.priorityFee = await promptNormalized(rl, "Priority fee (number or \"auto\")", "priorityFee", {
245
310
  current: nextCfg.priorityFee,
246
311
  });
247
312
 
248
313
  clearScreen();
249
314
  renderWizardHeader();
315
+ renderWizardFieldGuidance("priorityFeeLevel");
250
316
  nextCfg.priorityFeeLevel = await promptSelect(
251
317
  rl,
252
318
  "Priority fee level (used when priorityFee is auto)",
@@ -259,12 +325,14 @@ async function runConfigWizard({ cfg, rl }) {
259
325
 
260
326
  clearScreen();
261
327
  renderWizardHeader();
328
+ renderWizardFieldGuidance("txVersion");
262
329
  nextCfg.txVersion = await promptSelect(rl, "Transaction version", TX_VERSIONS, {
263
330
  current: nextCfg.txVersion,
264
331
  });
265
332
 
266
333
  clearScreen();
267
334
  renderWizardHeader();
335
+ renderWizardFieldGuidance("showQuoteDetails");
268
336
  const showQuoteDetails = await promptSelect(rl, "Show quote details", ["true", "false"], {
269
337
  current: nextCfg.showQuoteDetails ? "true" : "false",
270
338
  });
@@ -272,6 +340,7 @@ async function runConfigWizard({ cfg, rl }) {
272
340
 
273
341
  clearScreen();
274
342
  renderWizardHeader();
343
+ renderWizardFieldGuidance("DEBUG_MODE");
275
344
  const debugMode = await promptSelect(rl, "Enable debug mode", ["true", "false"], {
276
345
  current: nextCfg.DEBUG_MODE ? "true" : "false",
277
346
  });
@@ -279,6 +348,7 @@ async function runConfigWizard({ cfg, rl }) {
279
348
 
280
349
  clearScreen();
281
350
  renderWizardHeader();
351
+ renderWizardFieldGuidance("notificationsEnabled");
282
352
  const notificationsEnabled = await promptSelect(rl, "Enable notifications", ["true", "false"], {
283
353
  current: nextCfg.notificationsEnabled ? "true" : "false",
284
354
  });
@@ -286,6 +356,7 @@ async function runConfigWizard({ cfg, rl }) {
286
356
 
287
357
  clearScreen();
288
358
  renderWizardHeader();
359
+ renderWizardFieldGuidance("jitoEnabled");
289
360
  const jitoEnabled = await promptSelect(rl, "Enable Jito bundles", ["true", "false"], {
290
361
  current: nextCfg.jito.enabled ? "true" : "false",
291
362
  });
@@ -293,6 +364,7 @@ async function runConfigWizard({ cfg, rl }) {
293
364
  if (nextCfg.jito.enabled) {
294
365
  clearScreen();
295
366
  renderWizardHeader();
367
+ renderWizardFieldGuidance("jitoTip");
296
368
  const requireTip = nextCfg.jito.tip === undefined || nextCfg.jito.tip === null;
297
369
  nextCfg.jito.tip = await promptNumber(rl, "Jito tip (SOL)", {
298
370
  current: nextCfg.jito.tip,
@@ -311,25 +383,78 @@ const getTradeModule = async () => {
311
383
  return tradeModulePromise;
312
384
  };
313
385
 
314
- async function executeTrade(type, mint, amountArg) {
315
- const cfg = await loadConfig();
386
+ function getTradeCommandExample(type, mint = MINT_EXAMPLE) {
387
+ const amountExample = type === "buy" ? "0.01" : "auto";
388
+ return `summon ${type} ${mint} ${amountExample}`;
389
+ }
390
+
391
+ function renderTradeValidationErrors(type, validation) {
392
+ console.error(`Usage: summon ${type} [mint] [amount]`);
393
+ for (const issue of validation.issues) {
394
+ console.error(`⚠️ ${issue.message}`);
395
+ if (issue.field === "mint") {
396
+ console.error(` Example: ${getTradeCommandExample(type)}`);
397
+ continue;
398
+ }
399
+ if (issue.field === "amount") {
400
+ const amountExamples = getAmountExamples(type).join(", ");
401
+ const mintForExample = validation.mint || MINT_EXAMPLE;
402
+ console.error(` Amount examples: ${amountExamples}`);
403
+ console.error(` Example: ${getTradeCommandExample(type, mintForExample)}`);
404
+ }
405
+ }
406
+ }
316
407
 
317
- if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) {
318
- console.error("⚠️ Invalid mint format. Expected base58 address (32–44 chars).");
408
+ async function resolveTradeInput(type, mintArg, amountArg) {
409
+ let mint = mintArg;
410
+ let amount = amountArg;
411
+ let validation = validateTradeInput({ type, mint, amount });
412
+ if (validation.ok) {
413
+ return validation;
414
+ }
415
+
416
+ const canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY);
417
+ if (!canPrompt) {
418
+ renderTradeValidationErrors(type, validation);
319
419
  process.exit(1);
320
420
  }
321
421
 
322
- let amountParam = amountArg.toString().trim().toLowerCase().replace(/\s+/g, "");
422
+ const rl = readline.createInterface({
423
+ input: process.stdin,
424
+ output: process.stdout,
425
+ });
323
426
 
324
- if (amountParam !== "auto" && !amountParam.endsWith("%")) {
325
- const num = parseFloat(amountParam);
326
- if (isNaN(num) || num <= 0) {
327
- console.error("⚠️ Invalid amount. Use a positive number, 'auto' during a sell, or '<percent>%'.");
328
- process.exit(1);
427
+ try {
428
+ while (!validation.ok) {
429
+ const mintIssue = validation.issues.find((issue) => issue.field === "mint");
430
+ if (mintIssue) {
431
+ console.log(`⚠️ ${mintIssue.message}`);
432
+ console.log(` Example mint: ${MINT_EXAMPLE}`);
433
+ mint = await askQuestion(rl, "Mint address: ");
434
+ }
435
+
436
+ const amountIssue = validation.issues.find((issue) => issue.field === "amount");
437
+ if (amountIssue) {
438
+ console.log(`⚠️ ${amountIssue.message}`);
439
+ console.log(` Amount examples: ${getAmountExamples(type).join(", ")}`);
440
+ amount = await askQuestion(rl, "Amount: ");
441
+ }
442
+
443
+ validation = validateTradeInput({ type, mint, amount });
329
444
  }
330
- amountParam = num;
445
+ } finally {
446
+ rl.close();
331
447
  }
332
448
 
449
+ return validation;
450
+ }
451
+
452
+ async function executeTrade(type, mintArg, amountArg) {
453
+ const cfg = await loadConfig();
454
+ const validated = await resolveTradeInput(type, mintArg, amountArg);
455
+ const mint = validated.mint;
456
+ const amountParam = validated.amount;
457
+
333
458
  try {
334
459
  const mintDisplay = `${mint.slice(0, 4)}…${mint.slice(-4)}`;
335
460
  const amountDisplay = String(amountParam);
@@ -357,7 +482,7 @@ async function executeTrade(type, mint, amountArg) {
357
482
  }
358
483
 
359
484
  const { buyToken } = await getTradeModule();
360
- const result = await buyToken(mint, amountParam);
485
+ const result = await buyToken(mint, amountParam, { cfg });
361
486
  clearScreen();
362
487
  const info = `Received ${result.tokensReceivedDecimal} tokens | Fees ${result.totalFees} | Impact ${result.priceImpact}`;
363
488
  const buyRows = [
@@ -373,7 +498,7 @@ async function executeTrade(type, mint, amountArg) {
373
498
  }
374
499
  } else if (type === "sell") {
375
500
  const { sellToken } = await getTradeModule();
376
- const result = await sellToken(mint, amountParam);
501
+ const result = await sellToken(mint, amountParam, { cfg });
377
502
  clearScreen();
378
503
  const info = `Received ${result.solReceivedDecimal} SOL | Fees ${result.totalFees} | Impact ${result.priceImpact}`;
379
504
  const sellRows = [
@@ -609,15 +734,15 @@ keychainCmd
609
734
  });
610
735
 
611
736
  program
612
- .command("buy <mint> <amount>")
613
- .description("Buy a token with SOL")
737
+ .command("buy [mint] [amount]")
738
+ .description("Buy a token with SOL (prompts for missing values in interactive TTY)")
614
739
  .action(async (mint, amount) => {
615
740
  await executeTrade("buy", mint, amount);
616
741
  });
617
742
 
618
743
  program
619
- .command("sell <mint> <amount>")
620
- .description("Sell a token for SOL")
744
+ .command("sell [mint] [amount]")
745
+ .description("Sell a token for SOL (prompts for missing values in interactive TTY)")
621
746
  .action(async (mint, amount) => {
622
747
  await executeTrade("sell", mint, amount);
623
748
  });
@@ -659,13 +784,13 @@ program
659
784
  // Try base58 format
660
785
  const bytes = bs58.decode(rawKey);
661
786
  keypair = Keypair.fromSecretKey(bytes);
662
- } catch (err) {
787
+ } catch {
663
788
  try {
664
789
  // Try JSON array format
665
790
  const arr = JSON.parse(rawKey);
666
791
  if (!Array.isArray(arr)) throw new Error("Not an array");
667
792
  keypair = Keypair.fromSecretKey(Uint8Array.from(arr));
668
- } catch (jsonErr) {
793
+ } catch {
669
794
  throw new Error("Private key is neither base58 nor valid JSON array.");
670
795
  }
671
796
  }
@@ -679,6 +804,23 @@ program
679
804
  }
680
805
  });
681
806
 
807
+ function extractSuggestedCommands(results) {
808
+ const commands = new Set();
809
+ for (const result of results) {
810
+ if (result.status !== "fail" || !result.hint) {
811
+ continue;
812
+ }
813
+ const matches = [...result.hint.matchAll(/`([^`]+)`/g)];
814
+ for (const match of matches) {
815
+ const candidate = match[1].trim();
816
+ if (candidate.startsWith("summon ")) {
817
+ commands.add(candidate);
818
+ }
819
+ }
820
+ }
821
+ return [...commands];
822
+ }
823
+
682
824
  // DOCTOR command
683
825
  program
684
826
  .command("doctor")
@@ -689,12 +831,28 @@ program
689
831
  for (const result of results) {
690
832
  const icon = result.status === "ok" ? "βœ…" : result.status === "skip" ? "⚠️" : "❌";
691
833
  console.log(`${icon} ${result.name}: ${result.message}`);
834
+ if (result.status === "fail" && result.hint) {
835
+ console.log(` β€’ Hint: ${result.hint}`);
836
+ }
692
837
  if (options.verbose && result.details) {
693
838
  console.log(` β€’ ${result.details}`);
694
839
  }
695
840
  }
696
- const hasFailure = results.some((item) => item.status === "fail");
697
- process.exit(hasFailure ? 1 : 0);
841
+ const failures = results.filter((item) => item.status === "fail");
842
+ const suggestedCommands = extractSuggestedCommands(results);
843
+
844
+ console.log("");
845
+ console.log(
846
+ `Doctor summary: ${failures.length} failure${failures.length === 1 ? "" : "s"} out of ${results.length} checks.`
847
+ );
848
+ if (suggestedCommands.length) {
849
+ console.log("Suggested commands:");
850
+ for (const command of suggestedCommands) {
851
+ console.log(` β€’ ${command}`);
852
+ }
853
+ }
854
+
855
+ process.exit(failures.length ? 1 : 0);
698
856
  });
699
857
 
700
858
  // MANUAL command
@@ -751,12 +909,13 @@ USAGE:
751
909
  summon keychain delete
752
910
  Delete the private key from macOS Keychain
753
911
 
754
- summon buy <mint> <amount>
755
- summon sell <mint> <amount>
912
+ summon buy [mint] [amount]
913
+ summon sell [mint] [amount]
756
914
  Buy or sell a token. Amount formats:
757
915
  β€’ Fixed amount (e.g. 0.5 or 100)
758
916
  β€’ Percent of holdings (e.g. 50%)
759
917
  β€’ "auto" (sell only β€” sells your full balance)
918
+ In an interactive terminal, missing/invalid mint or amount will be prompted.
760
919
 
761
920
  summon wallet
762
921
  Open your wallet on SolanaTracker.io
package/utils/notify.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { NotificationError } from "../lib/errors.js";
3
3
  import { logger } from "./logger.js";
4
4
 
@@ -16,6 +16,8 @@ function escapeAppleScriptString(str) {
16
16
  * @param {string} [options.message=""]
17
17
  * @param {string} [options.subtitle=""]
18
18
  * @param {string} [options.sound]
19
+ * @param {boolean} [options.blocking] When true, waits for osascript completion.
20
+ * Defaults to throwOnError so strict callers stay blocking.
19
21
  */
20
22
  export function notify({
21
23
  title = "summonTheWarlord",
@@ -23,6 +25,7 @@ export function notify({
23
25
  subtitle = "",
24
26
  sound,
25
27
  throwOnError = false,
28
+ blocking = throwOnError,
26
29
  } = {}) {
27
30
  if (process.platform !== "darwin") {
28
31
  const bell = "\u0007";
@@ -42,10 +45,33 @@ export function notify({
42
45
  script += ` sound name "${escapeAppleScriptString(sound)}"`;
43
46
  }
44
47
 
45
- const result = spawnSync("osascript", ["-e", script], { stdio: "ignore" });
46
- if (result.error || result.status !== 0) {
47
- throw new NotificationError(`osascript exited with ${result.status}`, { cause: result.error });
48
+ if (blocking) {
49
+ const result = spawnSync("osascript", ["-e", script], { stdio: "ignore" });
50
+ if (result.error || result.status !== 0) {
51
+ throw new NotificationError(`osascript exited with ${result.status}`, { cause: result.error });
52
+ }
53
+ return true;
48
54
  }
55
+ let handledFailure = false;
56
+ const fallback = () => {
57
+ if (handledFailure) return;
58
+ handledFailure = true;
59
+ console.log(`πŸ”” ${title}: ${message}${subtitle ? ` - ${subtitle}` : ""}`);
60
+ };
61
+ const child = spawn("osascript", ["-e", script], { stdio: "ignore" });
62
+ child.once("error", (err) => {
63
+ logger.warn("Notification failed.", { error: err?.message });
64
+ fallback();
65
+ });
66
+ child.once("exit", (code) => {
67
+ if (code !== 0) {
68
+ logger.warn("Notification failed.", { error: `osascript exited with ${code}` });
69
+ fallback();
70
+ }
71
+ });
72
+ // Intentionally keep the child process referenced so CLI flows that call
73
+ // process.exit(...) immediately after notify still allow exit/error handlers
74
+ // to run and emit the console fallback when delivery fails.
49
75
  return true;
50
76
  } catch (err) {
51
77
  logger.warn("Notification failed.", { error: err?.message });