@moxxy/sdk 0.1.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 (211) hide show
  1. package/dist/agent.d.ts +39 -0
  2. package/dist/agent.d.ts.map +1 -0
  3. package/dist/agent.js +2 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/cache-strategy.d.ts +35 -0
  6. package/dist/cache-strategy.d.ts.map +1 -0
  7. package/dist/cache-strategy.js +2 -0
  8. package/dist/cache-strategy.js.map +1 -0
  9. package/dist/channel.d.ts +146 -0
  10. package/dist/channel.d.ts.map +1 -0
  11. package/dist/channel.js +2 -0
  12. package/dist/channel.js.map +1 -0
  13. package/dist/client-session.d.ts +83 -0
  14. package/dist/client-session.d.ts.map +1 -0
  15. package/dist/client-session.js +2 -0
  16. package/dist/client-session.js.map +1 -0
  17. package/dist/command.d.ts +90 -0
  18. package/dist/command.d.ts.map +1 -0
  19. package/dist/command.js +15 -0
  20. package/dist/command.js.map +1 -0
  21. package/dist/compactor-helpers.d.ts +34 -0
  22. package/dist/compactor-helpers.d.ts.map +1 -0
  23. package/dist/compactor-helpers.js +156 -0
  24. package/dist/compactor-helpers.js.map +1 -0
  25. package/dist/compactor.d.ts +20 -0
  26. package/dist/compactor.d.ts.map +1 -0
  27. package/dist/compactor.js +2 -0
  28. package/dist/compactor.js.map +1 -0
  29. package/dist/define.d.ts +51 -0
  30. package/dist/define.d.ts.map +1 -0
  31. package/dist/define.js +71 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/elision-helpers.d.ts +27 -0
  34. package/dist/elision-helpers.d.ts.map +1 -0
  35. package/dist/elision-helpers.js +109 -0
  36. package/dist/elision-helpers.js.map +1 -0
  37. package/dist/elision-state.d.ts +49 -0
  38. package/dist/elision-state.d.ts.map +1 -0
  39. package/dist/elision-state.js +144 -0
  40. package/dist/elision-state.js.map +1 -0
  41. package/dist/embedding-cache.d.ts +32 -0
  42. package/dist/embedding-cache.d.ts.map +1 -0
  43. package/dist/embedding-cache.js +0 -0
  44. package/dist/embedding-cache.js.map +1 -0
  45. package/dist/embedding.d.ts +21 -0
  46. package/dist/embedding.d.ts.map +1 -0
  47. package/dist/embedding.js +2 -0
  48. package/dist/embedding.js.map +1 -0
  49. package/dist/errors.d.ts +81 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +241 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/events.d.ts +193 -0
  54. package/dist/events.d.ts.map +1 -0
  55. package/dist/events.js +2 -0
  56. package/dist/events.js.map +1 -0
  57. package/dist/hooks.d.ts +52 -0
  58. package/dist/hooks.d.ts.map +1 -0
  59. package/dist/hooks.js +2 -0
  60. package/dist/hooks.js.map +1 -0
  61. package/dist/ids.d.ts +18 -0
  62. package/dist/ids.d.ts.map +1 -0
  63. package/dist/ids.js +7 -0
  64. package/dist/ids.js.map +1 -0
  65. package/dist/index.d.ts +45 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +18 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/install-hints.d.ts +26 -0
  70. package/dist/install-hints.d.ts.map +1 -0
  71. package/dist/install-hints.js +15 -0
  72. package/dist/install-hints.js.map +1 -0
  73. package/dist/isolation.d.ts +125 -0
  74. package/dist/isolation.d.ts.map +1 -0
  75. package/dist/isolation.js +11 -0
  76. package/dist/isolation.js.map +1 -0
  77. package/dist/log.d.ts +11 -0
  78. package/dist/log.d.ts.map +1 -0
  79. package/dist/log.js +2 -0
  80. package/dist/log.js.map +1 -0
  81. package/dist/mode-helpers.d.ts +99 -0
  82. package/dist/mode-helpers.d.ts.map +1 -0
  83. package/dist/mode-helpers.js +368 -0
  84. package/dist/mode-helpers.js.map +1 -0
  85. package/dist/mode.d.ts +133 -0
  86. package/dist/mode.d.ts.map +1 -0
  87. package/dist/mode.js +2 -0
  88. package/dist/mode.js.map +1 -0
  89. package/dist/permission.d.ts +35 -0
  90. package/dist/permission.d.ts.map +1 -0
  91. package/dist/permission.js +2 -0
  92. package/dist/permission.js.map +1 -0
  93. package/dist/plugin.d.ts +88 -0
  94. package/dist/plugin.d.ts.map +1 -0
  95. package/dist/plugin.js +2 -0
  96. package/dist/plugin.js.map +1 -0
  97. package/dist/provider-utils.d.ts +40 -0
  98. package/dist/provider-utils.d.ts.map +1 -0
  99. package/dist/provider-utils.js +163 -0
  100. package/dist/provider-utils.js.map +1 -0
  101. package/dist/provider.d.ts +219 -0
  102. package/dist/provider.d.ts.map +1 -0
  103. package/dist/provider.js +2 -0
  104. package/dist/provider.js.map +1 -0
  105. package/dist/requirements.d.ts +22 -0
  106. package/dist/requirements.d.ts.map +1 -0
  107. package/dist/requirements.js +2 -0
  108. package/dist/requirements.js.map +1 -0
  109. package/dist/schemas.d.ts +228 -0
  110. package/dist/schemas.d.ts.map +1 -0
  111. package/dist/schemas.js +88 -0
  112. package/dist/schemas.js.map +1 -0
  113. package/dist/session-like.d.ts +111 -0
  114. package/dist/session-like.d.ts.map +1 -0
  115. package/dist/session-like.js +2 -0
  116. package/dist/session-like.js.map +1 -0
  117. package/dist/skill.d.ts +30 -0
  118. package/dist/skill.d.ts.map +1 -0
  119. package/dist/skill.js +2 -0
  120. package/dist/skill.js.map +1 -0
  121. package/dist/subagent.d.ts +45 -0
  122. package/dist/subagent.d.ts.map +1 -0
  123. package/dist/subagent.js +11 -0
  124. package/dist/subagent.js.map +1 -0
  125. package/dist/token-accounting.d.ts +73 -0
  126. package/dist/token-accounting.d.ts.map +1 -0
  127. package/dist/token-accounting.js +129 -0
  128. package/dist/token-accounting.js.map +1 -0
  129. package/dist/tool-dispatch.d.ts +19 -0
  130. package/dist/tool-dispatch.d.ts.map +1 -0
  131. package/dist/tool-dispatch.js +118 -0
  132. package/dist/tool-dispatch.js.map +1 -0
  133. package/dist/tool-gating.d.ts +32 -0
  134. package/dist/tool-gating.d.ts.map +1 -0
  135. package/dist/tool-gating.js +83 -0
  136. package/dist/tool-gating.js.map +1 -0
  137. package/dist/tool.d.ts +166 -0
  138. package/dist/tool.d.ts.map +1 -0
  139. package/dist/tool.js +2 -0
  140. package/dist/tool.js.map +1 -0
  141. package/dist/transcriber.d.ts +64 -0
  142. package/dist/transcriber.d.ts.map +1 -0
  143. package/dist/transcriber.js +21 -0
  144. package/dist/transcriber.js.map +1 -0
  145. package/dist/tunnel.d.ts +25 -0
  146. package/dist/tunnel.d.ts.map +1 -0
  147. package/dist/tunnel.js +2 -0
  148. package/dist/tunnel.js.map +1 -0
  149. package/dist/type-contracts.d.ts +2 -0
  150. package/dist/type-contracts.d.ts.map +1 -0
  151. package/dist/type-contracts.js +4 -0
  152. package/dist/type-contracts.js.map +1 -0
  153. package/dist/types.test-d.d.ts +2 -0
  154. package/dist/types.test-d.d.ts.map +1 -0
  155. package/dist/types.test-d.js +22 -0
  156. package/dist/types.test-d.js.map +1 -0
  157. package/dist/view-renderer.d.ts +97 -0
  158. package/dist/view-renderer.d.ts.map +1 -0
  159. package/dist/view-renderer.js +72 -0
  160. package/dist/view-renderer.js.map +1 -0
  161. package/package.json +38 -0
  162. package/src/agent.ts +38 -0
  163. package/src/cache-strategy.ts +39 -0
  164. package/src/channel.ts +158 -0
  165. package/src/client-session.ts +88 -0
  166. package/src/command.ts +86 -0
  167. package/src/compactor-helpers.test.ts +206 -0
  168. package/src/compactor-helpers.ts +163 -0
  169. package/src/compactor.ts +20 -0
  170. package/src/define.test.ts +76 -0
  171. package/src/define.ts +113 -0
  172. package/src/elision-helpers.ts +143 -0
  173. package/src/elision-state.ts +170 -0
  174. package/src/embedding-cache.ts +0 -0
  175. package/src/embedding.ts +22 -0
  176. package/src/errors.test.ts +176 -0
  177. package/src/errors.ts +308 -0
  178. package/src/events.ts +250 -0
  179. package/src/hooks.ts +54 -0
  180. package/src/ids.ts +16 -0
  181. package/src/index.ts +304 -0
  182. package/src/install-hints.ts +43 -0
  183. package/src/isolation.ts +148 -0
  184. package/src/log.ts +11 -0
  185. package/src/loop-helpers.test.ts +78 -0
  186. package/src/mode-helpers.ts +483 -0
  187. package/src/mode.ts +139 -0
  188. package/src/package-root.test.ts +40 -0
  189. package/src/permission.ts +37 -0
  190. package/src/plugin.ts +92 -0
  191. package/src/provider-utils.test.ts +84 -0
  192. package/src/provider-utils.ts +171 -0
  193. package/src/provider.ts +195 -0
  194. package/src/requirements.ts +35 -0
  195. package/src/schemas.test.ts +106 -0
  196. package/src/schemas.ts +97 -0
  197. package/src/session-like.ts +118 -0
  198. package/src/skill.ts +34 -0
  199. package/src/subagent.ts +46 -0
  200. package/src/token-accounting.test.ts +93 -0
  201. package/src/token-accounting.ts +202 -0
  202. package/src/token-efficiency.test.ts +459 -0
  203. package/src/tool-dispatch.ts +137 -0
  204. package/src/tool-gating.ts +107 -0
  205. package/src/tool.ts +175 -0
  206. package/src/transcriber.ts +71 -0
  207. package/src/tunnel.ts +26 -0
  208. package/src/type-contracts.ts +4 -0
  209. package/src/types.test-d.ts +29 -0
  210. package/src/view-renderer.test.ts +90 -0
  211. package/src/view-renderer.ts +156 -0
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ MoxxyError,
4
+ classifyHttpStatus,
5
+ classifyNetworkError,
6
+ } from './errors.js';
7
+ import { toFriendlyError } from './provider-utils.js';
8
+
9
+ describe('MoxxyError', () => {
10
+ it('carries code, message, hint, context, cause', () => {
11
+ const cause = new Error('underlying');
12
+ const err = new MoxxyError({
13
+ code: 'NETWORK_UNREACHABLE',
14
+ message: 'down',
15
+ hint: 'check',
16
+ context: { url: 'https://x' },
17
+ cause,
18
+ });
19
+ expect(err.code).toBe('NETWORK_UNREACHABLE');
20
+ expect(err.message).toBe('down');
21
+ expect(err.hint).toBe('check');
22
+ expect(err.context).toEqual({ url: 'https://x' });
23
+ expect(err.cause).toBe(cause);
24
+ expect(err.name).toBe('MoxxyError');
25
+ });
26
+
27
+ it('isMoxxyError matches instances and name-tagged clones', () => {
28
+ const real = new MoxxyError({ code: 'INTERNAL', message: 'x' });
29
+ expect(MoxxyError.isMoxxyError(real)).toBe(true);
30
+ const fake = new Error('y');
31
+ fake.name = 'MoxxyError';
32
+ expect(MoxxyError.isMoxxyError(fake)).toBe(true);
33
+ expect(MoxxyError.isMoxxyError(new Error('z'))).toBe(false);
34
+ expect(MoxxyError.isMoxxyError('not an error')).toBe(false);
35
+ });
36
+
37
+ it('wrap() returns the original MoxxyError unchanged', () => {
38
+ const real = new MoxxyError({ code: 'AUTH_INVALID', message: 'no' });
39
+ const wrapped = MoxxyError.wrap(real, { code: 'INTERNAL', message: 'override' });
40
+ expect(wrapped).toBe(real);
41
+ });
42
+
43
+ it('wrap() upgrades an unknown error and stores the cause', () => {
44
+ const cause = new Error('boom');
45
+ const wrapped = MoxxyError.wrap(cause, {
46
+ code: 'NETWORK_UNREACHABLE',
47
+ message: 'wrapped',
48
+ });
49
+ expect(wrapped).toBeInstanceOf(MoxxyError);
50
+ expect(wrapped.code).toBe('NETWORK_UNREACHABLE');
51
+ expect(wrapped.cause).toBe(cause);
52
+ });
53
+ });
54
+
55
+ describe('classifyNetworkError', () => {
56
+ // Node's fetch nests the real reason in err.cause; build a fake of that shape.
57
+ function nodeFetchError(causeCode: string): Error {
58
+ const cause = new Error('inner') as Error & { code: string };
59
+ cause.code = causeCode;
60
+ return new TypeError('fetch failed', { cause });
61
+ }
62
+
63
+ it('returns null for non-Error values', () => {
64
+ expect(classifyNetworkError('nope')).toBeNull();
65
+ expect(classifyNetworkError(null)).toBeNull();
66
+ });
67
+
68
+ it('passes through an existing MoxxyError', () => {
69
+ const existing = new MoxxyError({ code: 'AUTH_INVALID', message: 'pre' });
70
+ expect(classifyNetworkError(existing)).toBe(existing);
71
+ });
72
+
73
+ it('maps ENOTFOUND to NETWORK_UNREACHABLE with a DNS-flavored hint', () => {
74
+ const out = classifyNetworkError(nodeFetchError('ENOTFOUND'), {
75
+ url: 'https://api.example.com/v1',
76
+ });
77
+ expect(out).not.toBeNull();
78
+ expect(out!.code).toBe('NETWORK_UNREACHABLE');
79
+ expect(out!.message).toContain('api.example.com');
80
+ expect(out!.hint).toMatch(/DNS|internet connection/i);
81
+ });
82
+
83
+ it('maps ECONNREFUSED to NETWORK_UNREACHABLE', () => {
84
+ const out = classifyNetworkError(nodeFetchError('ECONNREFUSED'), {
85
+ url: 'http://localhost:9999',
86
+ });
87
+ expect(out!.code).toBe('NETWORK_UNREACHABLE');
88
+ expect(out!.message).toContain('localhost');
89
+ });
90
+
91
+ it('maps ETIMEDOUT to NETWORK_TIMEOUT', () => {
92
+ const out = classifyNetworkError(nodeFetchError('ETIMEDOUT'), {
93
+ url: 'https://x.test',
94
+ });
95
+ expect(out!.code).toBe('NETWORK_TIMEOUT');
96
+ });
97
+
98
+ it('maps CERT_HAS_EXPIRED to NETWORK_TLS_FAILURE', () => {
99
+ const out = classifyNetworkError(nodeFetchError('CERT_HAS_EXPIRED'), {
100
+ url: 'https://x.test',
101
+ });
102
+ expect(out!.code).toBe('NETWORK_TLS_FAILURE');
103
+ });
104
+
105
+ it('maps AbortError to NETWORK_ABORTED', () => {
106
+ const e = new Error('aborted');
107
+ e.name = 'AbortError';
108
+ const out = classifyNetworkError(e, { url: 'https://x.test' });
109
+ expect(out!.code).toBe('NETWORK_ABORTED');
110
+ });
111
+
112
+ it('falls back when the message matches "fetch failed" but cause has no code', () => {
113
+ const out = classifyNetworkError(new TypeError('fetch failed'), { url: 'https://x.test' });
114
+ expect(out!.code).toBe('NETWORK_UNREACHABLE');
115
+ });
116
+
117
+ it('returns null for non-network errors so other handlers can take over', () => {
118
+ expect(classifyNetworkError(new Error('something else'))).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe('classifyHttpStatus', () => {
123
+ it('401 → AUTH_INVALID with a login hint', () => {
124
+ const out = classifyHttpStatus(401, { provider: 'anthropic' });
125
+ expect(out!.code).toBe('AUTH_INVALID');
126
+ expect(out!.hint).toMatch(/anthropic/);
127
+ });
128
+
129
+ it('403 → AUTH_DENIED', () => {
130
+ expect(classifyHttpStatus(403)!.code).toBe('AUTH_DENIED');
131
+ });
132
+
133
+ it('429 → PROVIDER_RATE_LIMITED', () => {
134
+ expect(classifyHttpStatus(429)!.code).toBe('PROVIDER_RATE_LIMITED');
135
+ });
136
+
137
+ it('5xx → PROVIDER_SERVER_ERROR', () => {
138
+ expect(classifyHttpStatus(503)!.code).toBe('PROVIDER_SERVER_ERROR');
139
+ });
140
+
141
+ it('400 → PROVIDER_BAD_REQUEST and echoes the body', () => {
142
+ const out = classifyHttpStatus(400, { body: 'tools array empty' });
143
+ expect(out!.code).toBe('PROVIDER_BAD_REQUEST');
144
+ expect(out!.message).toContain('tools array empty');
145
+ });
146
+
147
+ it('returns null for unmapped statuses (404, 418)', () => {
148
+ expect(classifyHttpStatus(404)).toBeNull();
149
+ expect(classifyHttpStatus(418)).toBeNull();
150
+ });
151
+ });
152
+
153
+ describe('toFriendlyError', () => {
154
+ function nodeFetchError(causeCode: string): Error {
155
+ const cause = new Error('inner') as Error & { code: string };
156
+ cause.code = causeCode;
157
+ return new TypeError('fetch failed', { cause });
158
+ }
159
+
160
+ it('upgrades "fetch failed" into a friendly classified message', () => {
161
+ const out = toFriendlyError(nodeFetchError('ENOTFOUND'), {
162
+ url: 'https://api.anthropic.com/v1',
163
+ provider: 'anthropic',
164
+ });
165
+ expect(out.message).not.toBe('fetch failed');
166
+ expect(out.message).toContain('api.anthropic.com');
167
+ expect(out.message).toMatch(/DNS|internet connection/i);
168
+ expect(out.retryable).toBe(true);
169
+ });
170
+
171
+ it('passes raw messages through when not network-shaped', () => {
172
+ const out = toFriendlyError(new Error('Unexpected response shape'));
173
+ expect(out.message).toBe('Unexpected response shape');
174
+ expect(out.retryable).toBe(false);
175
+ });
176
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Structured error type used across moxxy. Anything that bubbles to the
3
+ * user (CLI top-level, channel renderers) should throw a `MoxxyError`
4
+ * with a stable `code`, an actionable `message`, and ideally a `hint` for
5
+ * recovery. The CLI's bin.ts top-level handler formats these as:
6
+ *
7
+ * error [CODE] <message>
8
+ * hint: <hint>
9
+ *
10
+ * For raw / unknown errors that aren't ours (e.g. a Node `fetch` rejection
11
+ * for ECONNREFUSED), call `classifyNetworkError(err, { url })` at the
12
+ * fetch boundary to wrap them — that's where "fetch failed" turns into
13
+ * `NETWORK_UNREACHABLE` + a useful message.
14
+ */
15
+
16
+ /**
17
+ * Stable error codes. Keep this set small and behavioural ("what does the
18
+ * user need to do?") rather than exhaustively granular. New codes should
19
+ * earn their place by mapping to a different recovery action than any
20
+ * existing one.
21
+ */
22
+ export type MoxxyErrorCode =
23
+ // --- Network ---
24
+ | 'NETWORK_UNREACHABLE' // DNS failed / connection refused / no route
25
+ | 'NETWORK_TIMEOUT' // request timed out
26
+ | 'NETWORK_TLS_FAILURE' // bad cert, self-signed, etc.
27
+ | 'NETWORK_ABORTED' // user/signal aborted the request
28
+ // --- Auth ---
29
+ | 'AUTH_NO_CREDENTIALS' // no API key / OAuth token stored
30
+ | 'AUTH_EXPIRED' // token expired and refresh failed
31
+ | 'AUTH_INVALID' // 401 — credentials rejected by provider
32
+ | 'AUTH_DENIED' // 403 — credentials lack permission
33
+ // --- OAuth flow ---
34
+ | 'OAUTH_FLOW_TIMEOUT' // user didn't complete in time
35
+ | 'OAUTH_FLOW_DENIED' // user clicked deny
36
+ | 'OAUTH_FLOW_STATE_MISMATCH' // CSRF guard tripped
37
+ | 'OAUTH_FLOW_PORT_BUSY' // EADDRINUSE on callback server
38
+ | 'OAUTH_FLOW_NOT_SUPPORTED' // provider doesn't expose this flow
39
+ // --- Provider ---
40
+ | 'PROVIDER_NOT_CONFIGURED' // no active provider, no key
41
+ | 'PROVIDER_RATE_LIMITED' // 429
42
+ | 'PROVIDER_SERVER_ERROR' // 5xx
43
+ | 'PROVIDER_BAD_REQUEST' // 400 with provider-shaped message
44
+ | 'PROVIDER_UNKNOWN_RESPONSE' // 2xx but couldn't parse
45
+ // --- Vault ---
46
+ | 'VAULT_PASSPHRASE' // wrong passphrase
47
+ | 'VAULT_CORRUPT' // file unreadable / malformed
48
+ // --- Config / setup ---
49
+ | 'CONFIG_INVALID'
50
+ | 'PLUGIN_LOAD_FAILED'
51
+ | 'UNKNOWN_COMMAND'
52
+ // --- Catch-all ---
53
+ | 'INTERNAL';
54
+
55
+ export interface MoxxyErrorInit {
56
+ readonly code: MoxxyErrorCode;
57
+ readonly message: string;
58
+ readonly hint?: string;
59
+ /**
60
+ * Stable structured context — surfaced to logs / debug output, NOT in
61
+ * the default user-facing message. Use for `provider`, `url`, `status`,
62
+ * `provider_id`, etc.
63
+ */
64
+ readonly context?: Readonly<Record<string, string | number>>;
65
+ /** Underlying error, for chained-cause stack traces. */
66
+ readonly cause?: unknown;
67
+ }
68
+
69
+ /**
70
+ * Tagged error class. Use the constructor directly for fresh errors, or
71
+ * `MoxxyError.wrap` to upgrade an unknown thrown value while preserving
72
+ * the original cause. The CLI's top-level handler keys off `instanceof
73
+ * MoxxyError` to render the structured form.
74
+ */
75
+ export class MoxxyError extends Error {
76
+ readonly code: MoxxyErrorCode;
77
+ readonly hint?: string;
78
+ readonly context?: Readonly<Record<string, string | number>>;
79
+
80
+ constructor(init: MoxxyErrorInit) {
81
+ super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
82
+ this.name = 'MoxxyError';
83
+ this.code = init.code;
84
+ if (init.hint !== undefined) this.hint = init.hint;
85
+ if (init.context !== undefined) this.context = init.context;
86
+ }
87
+
88
+ static isMoxxyError(err: unknown): err is MoxxyError {
89
+ // `instanceof` works in-process; the name check rescues cases where
90
+ // the error crosses a module-realm boundary (rare but possible with
91
+ // dynamic imports through different SDK copies).
92
+ return err instanceof MoxxyError || (err instanceof Error && err.name === 'MoxxyError');
93
+ }
94
+
95
+ /**
96
+ * Convert an unknown thrown value to a MoxxyError. If `err` is already
97
+ * one, returns it unchanged. Otherwise builds a new error with the
98
+ * given code/message/hint and attaches `err` as `cause`.
99
+ */
100
+ static wrap(err: unknown, init: Omit<MoxxyErrorInit, 'cause'>): MoxxyError {
101
+ if (MoxxyError.isMoxxyError(err)) return err;
102
+ return new MoxxyError({ ...init, cause: err });
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Inspect a thrown value for Node networking signals and produce a
108
+ * MoxxyError when one matches. Returns `null` when the error doesn't
109
+ * look network-shaped — callers should fall through to other handlers
110
+ * (e.g. status-code mapping).
111
+ *
112
+ * Node's `fetch` (undici) hides the real reason inside `err.cause.code`
113
+ * (ECONNREFUSED, ENOTFOUND, ETIMEDOUT, CERT_HAS_EXPIRED, ...). Without
114
+ * unwrapping that we'd surface only "fetch failed" / "TypeError: failed
115
+ * to fetch", which is what triggered this whole error-rename effort.
116
+ */
117
+ export function classifyNetworkError(
118
+ err: unknown,
119
+ ctx: { readonly url?: string; readonly provider?: string } = {},
120
+ ): MoxxyError | null {
121
+ if (MoxxyError.isMoxxyError(err)) return err;
122
+ if (!(err instanceof Error)) return null;
123
+
124
+ const code = extractNodeErrorCode(err);
125
+ const url = ctx.url;
126
+ const target = url ? new URL(url).host : (ctx.provider ?? 'the upstream service');
127
+ const baseContext: Record<string, string> = {};
128
+ if (url) baseContext.url = url;
129
+ if (ctx.provider) baseContext.provider = ctx.provider;
130
+
131
+ // Abort first — distinct UX from a real failure.
132
+ if (
133
+ err.name === 'AbortError' ||
134
+ code === 'ABORT_ERR' ||
135
+ code === 'ERR_ABORTED'
136
+ ) {
137
+ return new MoxxyError({
138
+ code: 'NETWORK_ABORTED',
139
+ message: `Request to ${target} was aborted.`,
140
+ context: baseContext,
141
+ cause: err,
142
+ });
143
+ }
144
+
145
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
146
+ return new MoxxyError({
147
+ code: 'NETWORK_UNREACHABLE',
148
+ message: `DNS lookup failed for ${target}.`,
149
+ hint:
150
+ 'Check your internet connection. If you\'re on a corporate network, ' +
151
+ 'a DNS-blocking proxy may be in the way — set HTTPS_PROXY or try a different network.',
152
+ context: baseContext,
153
+ cause: err,
154
+ });
155
+ }
156
+ if (code === 'ECONNREFUSED') {
157
+ return new MoxxyError({
158
+ code: 'NETWORK_UNREACHABLE',
159
+ message: `Connection refused by ${target}.`,
160
+ hint:
161
+ 'The host actively rejected the connection. If you\'re targeting a local ' +
162
+ 'server, make sure it\'s running and listening on the expected port.',
163
+ context: baseContext,
164
+ cause: err,
165
+ });
166
+ }
167
+ if (code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
168
+ return new MoxxyError({
169
+ code: 'NETWORK_UNREACHABLE',
170
+ message: `No route to ${target}.`,
171
+ hint: 'Check your internet connection or VPN.',
172
+ context: baseContext,
173
+ cause: err,
174
+ });
175
+ }
176
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT') {
177
+ return new MoxxyError({
178
+ code: 'NETWORK_TIMEOUT',
179
+ message: `Request to ${target} timed out.`,
180
+ hint:
181
+ 'The host accepted the connection but didn\'t respond in time. Retry, ' +
182
+ 'or check whether the service is experiencing an outage.',
183
+ context: baseContext,
184
+ cause: err,
185
+ });
186
+ }
187
+ if (
188
+ code === 'CERT_HAS_EXPIRED' ||
189
+ code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
190
+ code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
191
+ code === 'SELF_SIGNED_CERT_IN_CHAIN' ||
192
+ (typeof code === 'string' && code.startsWith('ERR_TLS'))
193
+ ) {
194
+ return new MoxxyError({
195
+ code: 'NETWORK_TLS_FAILURE',
196
+ message: `TLS handshake with ${target} failed (${code}).`,
197
+ hint:
198
+ 'The server\'s certificate couldn\'t be verified. This usually means a ' +
199
+ 'self-signed cert or an MITM proxy on your network.',
200
+ context: baseContext,
201
+ cause: err,
202
+ });
203
+ }
204
+ if (code === 'ECONNRESET') {
205
+ return new MoxxyError({
206
+ code: 'NETWORK_UNREACHABLE',
207
+ message: `Connection to ${target} was reset.`,
208
+ hint: 'The server dropped the connection mid-request. Retry, or check the service status.',
209
+ context: baseContext,
210
+ cause: err,
211
+ });
212
+ }
213
+
214
+ // Last-chance heuristics on the message text. Node fetch's outer
215
+ // message is just "fetch failed"; the useful signal is in `cause`, but
216
+ // some libraries flatten that.
217
+ const msg = err.message.toLowerCase();
218
+ if (msg === 'fetch failed' || msg.includes('failed to fetch')) {
219
+ return new MoxxyError({
220
+ code: 'NETWORK_UNREACHABLE',
221
+ message: `Couldn't reach ${target}.`,
222
+ hint: 'Check your internet connection or any proxy/firewall settings.',
223
+ context: baseContext,
224
+ cause: err,
225
+ });
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Walk `err.cause` chain looking for a Node-style `code` (e.g. on
233
+ * `SystemError`, `DOMException`, undici's `UND_*` codes). Node nests the
234
+ * real reason under `cause` for fetch errors.
235
+ */
236
+ function extractNodeErrorCode(err: unknown, depth = 0): string | undefined {
237
+ if (depth > 4) return undefined;
238
+ if (!err || typeof err !== 'object') return undefined;
239
+ const e = err as { code?: unknown; cause?: unknown; name?: unknown };
240
+ if (typeof e.code === 'string' && e.code.length > 0) return e.code;
241
+ if (e.cause) return extractNodeErrorCode(e.cause, depth + 1);
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Build a MoxxyError from an HTTP response that came back with a 4xx/5xx.
247
+ * Maps common status codes to auth / rate-limit / server-error codes.
248
+ * Pass the response body text in `body` so the message can echo the
249
+ * provider's reason. Returns `null` for unmapped status codes (caller
250
+ * should fall back to a generic error).
251
+ */
252
+ export function classifyHttpStatus(
253
+ status: number,
254
+ ctx: { readonly url?: string; readonly provider?: string; readonly body?: string } = {},
255
+ ): MoxxyError | null {
256
+ const target = ctx.provider ?? (ctx.url ? new URL(ctx.url).host : 'the upstream service');
257
+ const tail = ctx.body ? ` — ${truncate(ctx.body, 200)}` : '';
258
+ const context: Record<string, string | number> = { status };
259
+ if (ctx.url) context.url = ctx.url;
260
+ if (ctx.provider) context.provider = ctx.provider;
261
+
262
+ if (status === 401) {
263
+ return new MoxxyError({
264
+ code: 'AUTH_INVALID',
265
+ message: `${target} rejected the credentials (401).${tail}`,
266
+ hint:
267
+ `Run \`moxxy login ${ctx.provider ?? '<provider>'}\` (OAuth) or check your API key.`,
268
+ context,
269
+ });
270
+ }
271
+ if (status === 403) {
272
+ return new MoxxyError({
273
+ code: 'AUTH_DENIED',
274
+ message: `${target} denied the request (403).${tail}`,
275
+ hint: 'Your credentials are valid but lack permission for this resource.',
276
+ context,
277
+ });
278
+ }
279
+ if (status === 429) {
280
+ return new MoxxyError({
281
+ code: 'PROVIDER_RATE_LIMITED',
282
+ message: `${target} is rate-limiting requests (429).${tail}`,
283
+ hint: 'Back off and retry. Check your plan limits if this keeps happening.',
284
+ context,
285
+ });
286
+ }
287
+ if (status >= 500 && status < 600) {
288
+ return new MoxxyError({
289
+ code: 'PROVIDER_SERVER_ERROR',
290
+ message: `${target} returned a server error (${status}).${tail}`,
291
+ hint: 'This is on the provider\'s side. Retry, or check their status page.',
292
+ context,
293
+ });
294
+ }
295
+ if (status === 400) {
296
+ return new MoxxyError({
297
+ code: 'PROVIDER_BAD_REQUEST',
298
+ message: `${target} rejected the request as malformed (400).${tail}`,
299
+ context,
300
+ });
301
+ }
302
+ return null;
303
+ }
304
+
305
+ function truncate(s: string, max: number): string {
306
+ if (s.length <= max) return s;
307
+ return `${s.slice(0, max - 1)}…`;
308
+ }