@jango-blockchained/hoox-cli 0.3.4 → 0.3.5

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 (48) hide show
  1. package/README.md +2 -0
  2. package/package.json +5 -3
  3. package/src/commands/check/check-command.test.ts +28 -20
  4. package/src/commands/check/prerequisites-command.ts +9 -5
  5. package/src/commands/clone/clone-command.ts +1 -10
  6. package/src/commands/config/config-command.ts +7 -11
  7. package/src/commands/config/env-command.ts +16 -35
  8. package/src/commands/config/kv-command.ts +33 -72
  9. package/src/commands/db/db-command.ts +23 -25
  10. package/src/commands/deploy/deploy-command.ts +133 -52
  11. package/src/commands/deploy/telegram-service.ts +21 -6
  12. package/src/commands/dev/dev-command.ts +7 -16
  13. package/src/commands/infra/infra-command.test.ts +2 -2
  14. package/src/commands/infra/infra-command.ts +45 -11
  15. package/src/commands/init/init-command.test.ts +2 -2
  16. package/src/commands/monitor/monitor-command.test.ts +44 -16
  17. package/src/commands/monitor/monitor-command.ts +45 -21
  18. package/src/commands/monitor/monitor-service.ts +19 -4
  19. package/src/commands/repair/repair-command.test.ts +35 -15
  20. package/src/commands/repair/repair-command.ts +39 -18
  21. package/src/commands/repair/repair-service.ts +48 -12
  22. package/src/commands/test/test-command.ts +4 -10
  23. package/src/commands/tui/index.ts +1 -0
  24. package/src/commands/tui/tui-command.ts +87 -0
  25. package/src/commands/update/index.ts +1 -0
  26. package/src/commands/update/update-command.ts +51 -0
  27. package/src/commands/waf/waf-command.test.ts +1 -1
  28. package/src/commands/waf/waf-command.ts +11 -11
  29. package/src/index.ts +52 -1
  30. package/src/services/cloudflare/cloudflare-service.test.ts +12 -8
  31. package/src/services/cloudflare/cloudflare-service.ts +8 -10
  32. package/src/services/cloudflare/types.ts +6 -5
  33. package/src/services/db/db-service.ts +8 -17
  34. package/src/services/env/env-service.test.ts +41 -15
  35. package/src/services/env/env-service.ts +276 -46
  36. package/src/services/kv/kv-sync-service.ts +117 -34
  37. package/src/services/prerequisites/prerequisites-service.ts +137 -29
  38. package/src/services/secrets/index.ts +0 -1
  39. package/src/services/secrets/secrets-service.test.ts +34 -25
  40. package/src/services/secrets/secrets-service.ts +10 -16
  41. package/src/services/secrets/types.ts +4 -11
  42. package/src/services/update/index.ts +2 -0
  43. package/src/services/update/update-service.test.ts +76 -0
  44. package/src/services/update/update-service.ts +193 -0
  45. package/src/ui/banner.ts +5 -5
  46. package/src/ui/menu.ts +6 -1
  47. package/src/utils/formatters.ts +21 -4
  48. package/src/utils/theme.ts +1 -1
@@ -25,7 +25,10 @@ export class KvSyncService {
25
25
 
26
26
  if (exitCode === 0) {
27
27
  try {
28
- const namespaces = JSON.parse(stdout) as Array<{ id: string; title: string }>;
28
+ const namespaces = JSON.parse(stdout) as Array<{
29
+ id: string;
30
+ title: string;
31
+ }>;
29
32
  const configKv = namespaces.find((n) => n.title === "CONFIG_KV");
30
33
  if (configKv) return configKv.id;
31
34
  } catch {
@@ -37,23 +40,21 @@ export class KvSyncService {
37
40
  }
38
41
 
39
42
  throw new Error(
40
- "Could not resolve CONFIG_KV namespace ID. Provide --namespace-id flag.",
43
+ "Could not resolve CONFIG_KV namespace ID. Provide --namespace-id flag."
41
44
  );
42
45
  }
43
46
 
44
- async list(
45
- namespaceId: string,
46
- ): Promise<Array<{ name: string }>> {
47
+ async list(namespaceId: string): Promise<Array<{ name: string }>> {
47
48
  const proc = Bun.spawn(
48
49
  ["wrangler", "kv:key", "list", "--namespace-id", namespaceId],
49
- { stdout: "pipe", stderr: "pipe" },
50
+ { stdout: "pipe", stderr: "pipe" }
50
51
  );
51
52
  const stdout = await new Response(proc.stdout).text();
52
53
  const exitCode = await proc.exited;
53
54
 
54
55
  if (exitCode !== 0) {
55
56
  throw new Error(
56
- `Failed to list KV keys: ${stdout.trim() || `exit ${exitCode}`}`,
57
+ `Failed to list KV keys: ${stdout.trim() || `exit ${exitCode}`}`
57
58
  );
58
59
  }
59
60
 
@@ -71,7 +72,7 @@ export class KvSyncService {
71
72
  async get(namespaceId: string, key: string): Promise<string | null> {
72
73
  const proc = Bun.spawn(
73
74
  ["wrangler", "kv:key", "get", "--namespace-id", namespaceId, key],
74
- { stdout: "pipe", stderr: "pipe" },
75
+ { stdout: "pipe", stderr: "pipe" }
75
76
  );
76
77
  const stdout = await new Response(proc.stdout).text();
77
78
  const stderr = await new Response(proc.stderr).text();
@@ -80,28 +81,24 @@ export class KvSyncService {
80
81
  if (exitCode !== 0) {
81
82
  if (stderr.includes("not found")) return null;
82
83
  throw new Error(
83
- `Failed to get key "${key}": ${stderr.trim() || `exit ${exitCode}`}`,
84
+ `Failed to get key "${key}": ${stderr.trim() || `exit ${exitCode}`}`
84
85
  );
85
86
  }
86
87
 
87
88
  return stdout.trim() || null;
88
89
  }
89
90
 
90
- async set(
91
- namespaceId: string,
92
- key: string,
93
- value: string,
94
- ): Promise<void> {
91
+ async set(namespaceId: string, key: string, value: string): Promise<void> {
95
92
  const proc = Bun.spawn(
96
93
  ["wrangler", "kv:key", "put", "--namespace-id", namespaceId, key, value],
97
- { stdout: "pipe", stderr: "pipe" },
94
+ { stdout: "pipe", stderr: "pipe" }
98
95
  );
99
96
  const exitCode = await proc.exited;
100
97
  const stderr = await new Response(proc.stderr).text();
101
98
 
102
99
  if (exitCode !== 0) {
103
100
  throw new Error(
104
- `Failed to set key "${key}": ${stderr.trim() || `exit ${exitCode}`}`,
101
+ `Failed to set key "${key}": ${stderr.trim() || `exit ${exitCode}`}`
105
102
  );
106
103
  }
107
104
  }
@@ -109,14 +106,14 @@ export class KvSyncService {
109
106
  async delete(namespaceId: string, key: string): Promise<void> {
110
107
  const proc = Bun.spawn(
111
108
  ["wrangler", "kv:key", "delete", "--namespace-id", namespaceId, key],
112
- { stdout: "pipe", stderr: "pipe" },
109
+ { stdout: "pipe", stderr: "pipe" }
113
110
  );
114
111
  const exitCode = await proc.exited;
115
112
  const stderr = await new Response(proc.stderr).text();
116
113
 
117
114
  if (exitCode !== 0) {
118
115
  throw new Error(
119
- `Failed to delete key "${key}": ${stderr.trim() || `exit ${exitCode}`}`,
116
+ `Failed to delete key "${key}": ${stderr.trim() || `exit ${exitCode}`}`
120
117
  );
121
118
  }
122
119
  }
@@ -125,22 +122,108 @@ export class KvSyncService {
125
122
  return {
126
123
  namespace: "CONFIG_KV",
127
124
  keys: [
128
- { key: "webhook:tradingview:ip_check_enabled", type: "boolean", default: "false", description: "Enable TradingView IP allowlist check" },
129
- { key: "webhook:allowed_ips", type: "string", default: "", description: "Comma-separated allowed IPs for webhook" },
130
- { key: "routing:dynamic:enabled", type: "boolean", default: "false", description: "Enable dynamic routing" },
131
- { key: "trade:max_daily_drawdown_percent", type: "number", default: "10", description: "Max daily loss percentage before halt" },
132
- { key: "trade:kill_switch", type: "boolean", default: "false", description: "Emergency stop all trading" },
133
- { key: "trade:watermark:{exchange}:{symbol}:{side}", type: "number", default: "", description: "Per-market watermark (template key)", secret: true },
134
- { key: "agent:openai_key", type: "string", default: "", description: "OpenAI API key for AI agent", secret: true },
135
- { key: "agent:anthropic_key", type: "string", default: "", description: "Anthropic API key for AI agent", secret: true },
136
- { key: "agent:google_key", type: "string", default: "", description: "Google AI API key for agent", secret: true },
137
- { key: "agent:azure_api_key", type: "string", default: "", description: "Azure OpenAI API key", secret: true },
138
- { key: "agent:azure_endpoint", type: "string", default: "", description: "Azure OpenAI endpoint", secret: true },
139
- { key: "email:scan_subject", type: "string", default: "", description: "Email subject filter for signal scanning" },
140
- { key: "email:coin_pattern", type: "string", default: "", description: "Regex pattern to extract coin from email" },
141
- { key: "email:action_pattern", type: "string", default: "", description: "Regex pattern to extract action from email" },
142
- { key: "email:quantity_multiplier", type: "number", default: "1", description: "Multiplier for email signal quantity" },
143
- { key: "email:use_imap", type: "boolean", default: "false", description: "Use IMAP for email scanning" },
125
+ {
126
+ key: "webhook:tradingview:ip_check_enabled",
127
+ type: "boolean",
128
+ default: "false",
129
+ description: "Enable TradingView IP allowlist check",
130
+ },
131
+ {
132
+ key: "webhook:allowed_ips",
133
+ type: "string",
134
+ default: "",
135
+ description: "Comma-separated allowed IPs for webhook",
136
+ },
137
+ {
138
+ key: "routing:dynamic:enabled",
139
+ type: "boolean",
140
+ default: "false",
141
+ description: "Enable dynamic routing",
142
+ },
143
+ {
144
+ key: "trade:max_daily_drawdown_percent",
145
+ type: "number",
146
+ default: "10",
147
+ description: "Max daily loss percentage before halt",
148
+ },
149
+ {
150
+ key: "trade:kill_switch",
151
+ type: "boolean",
152
+ default: "false",
153
+ description: "Emergency stop all trading",
154
+ },
155
+ {
156
+ key: "trade:watermark:{exchange}:{symbol}:{side}",
157
+ type: "number",
158
+ default: "",
159
+ description: "Per-market watermark (template key)",
160
+ secret: true,
161
+ },
162
+ {
163
+ key: "agent:openai_key",
164
+ type: "string",
165
+ default: "",
166
+ description: "OpenAI API key for AI agent",
167
+ secret: true,
168
+ },
169
+ {
170
+ key: "agent:anthropic_key",
171
+ type: "string",
172
+ default: "",
173
+ description: "Anthropic API key for AI agent",
174
+ secret: true,
175
+ },
176
+ {
177
+ key: "agent:google_key",
178
+ type: "string",
179
+ default: "",
180
+ description: "Google AI API key for agent",
181
+ secret: true,
182
+ },
183
+ {
184
+ key: "agent:azure_api_key",
185
+ type: "string",
186
+ default: "",
187
+ description: "Azure OpenAI API key",
188
+ secret: true,
189
+ },
190
+ {
191
+ key: "agent:azure_endpoint",
192
+ type: "string",
193
+ default: "",
194
+ description: "Azure OpenAI endpoint",
195
+ secret: true,
196
+ },
197
+ {
198
+ key: "email:scan_subject",
199
+ type: "string",
200
+ default: "",
201
+ description: "Email subject filter for signal scanning",
202
+ },
203
+ {
204
+ key: "email:coin_pattern",
205
+ type: "string",
206
+ default: "",
207
+ description: "Regex pattern to extract coin from email",
208
+ },
209
+ {
210
+ key: "email:action_pattern",
211
+ type: "string",
212
+ default: "",
213
+ description: "Regex pattern to extract action from email",
214
+ },
215
+ {
216
+ key: "email:quantity_multiplier",
217
+ type: "number",
218
+ default: "1",
219
+ description: "Multiplier for email signal quantity",
220
+ },
221
+ {
222
+ key: "email:use_imap",
223
+ type: "boolean",
224
+ default: "false",
225
+ description: "Use IMAP for email scanning",
226
+ },
144
227
  ],
145
228
  };
146
229
  }
@@ -100,14 +100,27 @@ export class PrerequisitesService {
100
100
  async checkBun(): Promise<PrerequisiteCheck> {
101
101
  const base = { name: "Bun", category: "tool" as const, required: ">=1.2" };
102
102
  try {
103
- const proc = Bun.spawn(["bun", "--version"], { stdout: "pipe", stderr: "pipe" });
103
+ const proc = Bun.spawn(["bun", "--version"], {
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ });
104
107
  const stdout = await new Response(proc.stdout).text();
105
108
  const version = stdout.trim();
106
109
  const [major, minor] = version.split(".").map(Number);
107
110
  const passed = (major ?? 0) >= 1 && (minor ?? 0) >= 2;
108
- return { ...base, passed, version: version || "not found", hint: passed ? undefined : "Install: curl -fsSL https://bun.sh | bash" };
111
+ return {
112
+ ...base,
113
+ passed,
114
+ version: version || "not found",
115
+ hint: passed ? undefined : "Install: curl -fsSL https://bun.sh | bash",
116
+ };
109
117
  } catch {
110
- return { ...base, passed: false, version: "not found", hint: "Install: curl -fsSL https://bun.sh | bash" };
118
+ return {
119
+ ...base,
120
+ passed: false,
121
+ version: "not found",
122
+ hint: "Install: curl -fsSL https://bun.sh | bash",
123
+ };
111
124
  }
112
125
  }
113
126
 
@@ -118,15 +131,28 @@ export class PrerequisitesService {
118
131
  async checkGit(): Promise<PrerequisiteCheck> {
119
132
  const base = { name: "Git", category: "tool" as const, required: ">=2.40" };
120
133
  try {
121
- const proc = Bun.spawn(["git", "--version"], { stdout: "pipe", stderr: "pipe" });
134
+ const proc = Bun.spawn(["git", "--version"], {
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ });
122
138
  const stdout = await new Response(proc.stdout).text();
123
139
  const match = stdout.match(/(\d+\.\d+)/);
124
140
  const version = match ? match[1] : "unknown";
125
141
  const [major, minor] = version.split(".").map(Number);
126
142
  const passed = (major ?? 0) >= 2 && (minor ?? 0) >= 40;
127
- return { ...base, passed, version, hint: passed ? undefined : "Install: apt install git" };
143
+ return {
144
+ ...base,
145
+ passed,
146
+ version,
147
+ hint: passed ? undefined : "Install: apt install git",
148
+ };
128
149
  } catch {
129
- return { ...base, passed: false, version: "not found", hint: "Install: apt install git" };
150
+ return {
151
+ ...base,
152
+ passed: false,
153
+ version: "not found",
154
+ hint: "Install: apt install git",
155
+ };
130
156
  }
131
157
  }
132
158
 
@@ -135,16 +161,33 @@ export class PrerequisitesService {
135
161
  * Runs `node --version` if available, skips gracefully if not installed.
136
162
  */
137
163
  async checkNode(): Promise<PrerequisiteCheck> {
138
- const base = { name: "Node.js", category: "tool" as const, required: ">=18 (optional)" };
164
+ const base = {
165
+ name: "Node.js",
166
+ category: "tool" as const,
167
+ required: ">=18 (optional)",
168
+ };
139
169
  try {
140
- const proc = Bun.spawn(["node", "--version"], { stdout: "pipe", stderr: "pipe" });
170
+ const proc = Bun.spawn(["node", "--version"], {
171
+ stdout: "pipe",
172
+ stderr: "pipe",
173
+ });
141
174
  const stdout = await new Response(proc.stdout).text();
142
175
  const version = stdout.trim().replace(/^v/, "");
143
176
  const [major] = version.split(".").map(Number);
144
177
  const passed = (major ?? 0) >= 18;
145
- return { ...base, passed, version: version || "not found", hint: passed ? undefined : "Install: nvm or https://nodejs.org" };
178
+ return {
179
+ ...base,
180
+ passed,
181
+ version: version || "not found",
182
+ hint: passed ? undefined : "Install: nvm or https://nodejs.org",
183
+ };
146
184
  } catch {
147
- return { ...base, passed: true, version: "not installed", hint: "Optional — not required when using Bun" };
185
+ return {
186
+ ...base,
187
+ passed: true,
188
+ version: "not installed",
189
+ hint: "Optional — not required when using Bun",
190
+ };
148
191
  }
149
192
  }
150
193
 
@@ -153,16 +196,27 @@ export class PrerequisitesService {
153
196
  * Delegates to the existing method to avoid duplicating semver logic.
154
197
  */
155
198
  async checkWrangler(): Promise<PrerequisiteCheck> {
156
- const base = { name: "Wrangler CLI", category: "tool" as const, required: `>=${this.MINIMUM_WRANGLER}` };
199
+ const base = {
200
+ name: "Wrangler CLI",
201
+ category: "tool" as const,
202
+ required: `>=${this.MINIMUM_WRANGLER}`,
203
+ };
157
204
  const result = await this.checkWranglerVersion();
158
205
  if (!result.current) {
159
- return { ...base, passed: false, version: "not found", hint: "Install: bun add -g wrangler" };
206
+ return {
207
+ ...base,
208
+ passed: false,
209
+ version: "not found",
210
+ hint: "Install: bun add -g wrangler",
211
+ };
160
212
  }
161
213
  return {
162
214
  ...base,
163
215
  passed: !result.outdated,
164
216
  version: result.current,
165
- hint: result.outdated ? `Update: bun add -g wrangler@latest (${result.current} < ${result.minimum})` : undefined,
217
+ hint: result.outdated
218
+ ? `Update: bun add -g wrangler@latest (${result.current} < ${result.minimum})`
219
+ : undefined,
166
220
  };
167
221
  }
168
222
 
@@ -171,9 +225,16 @@ export class PrerequisitesService {
171
225
  * Verifies the CLI is authenticated and extracts the user email.
172
226
  */
173
227
  async checkCloudflareAuth(): Promise<PrerequisiteCheck> {
174
- const base = { name: "Cloudflare Auth", category: "account" as const, required: "wrangler whoami" };
228
+ const base = {
229
+ name: "Cloudflare Auth",
230
+ category: "account" as const,
231
+ required: "wrangler whoami",
232
+ };
175
233
  try {
176
- const proc = Bun.spawn(["wrangler", "whoami"], { stdout: "pipe", stderr: "pipe" });
234
+ const proc = Bun.spawn(["wrangler", "whoami"], {
235
+ stdout: "pipe",
236
+ stderr: "pipe",
237
+ });
177
238
  const stdout = await new Response(proc.stdout).text();
178
239
  const exitCode = await proc.exited;
179
240
  const passed = exitCode === 0 && !stdout.includes("not authenticated");
@@ -185,7 +246,12 @@ export class PrerequisitesService {
185
246
  hint: passed ? undefined : "Run: wrangler login",
186
247
  };
187
248
  } catch {
188
- return { ...base, passed: false, version: "not authenticated", hint: "Run: wrangler login" };
249
+ return {
250
+ ...base,
251
+ passed: false,
252
+ version: "not authenticated",
253
+ hint: "Run: wrangler login",
254
+ };
189
255
  }
190
256
  }
191
257
 
@@ -194,23 +260,44 @@ export class PrerequisitesService {
194
260
  * Also checks `docker compose` availability separately.
195
261
  */
196
262
  async checkDocker(): Promise<PrerequisiteCheck> {
197
- const base = { name: "Docker", category: "tool" as const, required: "optional" };
263
+ const base = {
264
+ name: "Docker",
265
+ category: "tool" as const,
266
+ required: "optional",
267
+ };
198
268
  try {
199
- const proc = Bun.spawn(["docker", "--version"], { stdout: "pipe", stderr: "pipe" });
269
+ const proc = Bun.spawn(["docker", "--version"], {
270
+ stdout: "pipe",
271
+ stderr: "pipe",
272
+ });
200
273
  const stdout = await new Response(proc.stdout).text();
201
- const version = stdout.trim().replace(/^Docker version /, "").replace(/,.*$/, "");
274
+ const version = stdout
275
+ .trim()
276
+ .replace(/^Docker version /, "")
277
+ .replace(/,.*$/, "");
202
278
  let composeVersion = "";
203
279
  try {
204
- const composeProc = Bun.spawn(["docker", "compose", "version"], { stdout: "pipe", stderr: "pipe" });
280
+ const composeProc = Bun.spawn(["docker", "compose", "version"], {
281
+ stdout: "pipe",
282
+ stderr: "pipe",
283
+ });
205
284
  composeVersion = (await new Response(composeProc.stdout).text()).trim();
206
- } catch { /* compose optional */ }
285
+ } catch {
286
+ /* compose optional */
287
+ }
207
288
  return {
208
289
  ...base,
209
290
  passed: true,
210
- version: version + (composeVersion ? ` (compose: yes)` : " (compose: no)"),
291
+ version:
292
+ version + (composeVersion ? ` (compose: yes)` : " (compose: no)"),
211
293
  };
212
294
  } catch {
213
- return { ...base, passed: true, version: "not installed", hint: "Optional — used for Docker dev runtime" };
295
+ return {
296
+ ...base,
297
+ passed: true,
298
+ version: "not installed",
299
+ hint: "Optional — used for Docker dev runtime",
300
+ };
214
301
  }
215
302
  }
216
303
 
@@ -218,28 +305,49 @@ export class PrerequisitesService {
218
305
  * Check repository integrity: wrangler.jsonc exists, .env file present, submodules initialized.
219
306
  */
220
307
  async checkRepository(): Promise<PrerequisiteCheck> {
221
- const base = { name: "Repository", category: "repository" as const, required: "valid" };
308
+ const base = {
309
+ name: "Repository",
310
+ category: "repository" as const,
311
+ required: "valid",
312
+ };
222
313
  try {
223
314
  const wranglerExists = await Bun.file("wrangler.jsonc").exists();
224
- const envExists = await Bun.file(".env.local").exists() || await Bun.file(".env.example").exists();
315
+ const envExists =
316
+ (await Bun.file(".env.local").exists()) ||
317
+ (await Bun.file(".env.example").exists());
225
318
  const issues: string[] = [];
226
319
  if (!wranglerExists) issues.push("wrangler.jsonc not found");
227
320
  if (!envExists) issues.push("no .env.local or .env.example found");
228
321
  let submoduleOk = false;
229
322
  try {
230
- const proc = Bun.spawn(["git", "submodule", "status"], { stdout: "pipe", stderr: "pipe" });
323
+ const proc = Bun.spawn(["git", "submodule", "status"], {
324
+ stdout: "pipe",
325
+ stderr: "pipe",
326
+ });
231
327
  const stdout = await new Response(proc.stdout).text();
232
328
  submoduleOk = !stdout.startsWith("-");
233
- } catch { /* not a git repo */ }
329
+ } catch {
330
+ /* not a git repo */
331
+ }
234
332
  const passed = wranglerExists && issues.length === 0;
235
333
  return {
236
334
  ...base,
237
335
  passed,
238
- version: issues.length > 0 ? issues.join("; ") : (submoduleOk ? "OK" : "submodules may need init"),
336
+ version:
337
+ issues.length > 0
338
+ ? issues.join("; ")
339
+ : submoduleOk
340
+ ? "OK"
341
+ : "submodules may need init",
239
342
  hint: passed ? undefined : "Run: hoox init or hoox clone --all",
240
343
  };
241
344
  } catch {
242
- return { ...base, passed: false, version: "check failed", hint: "Check repository structure" };
345
+ return {
346
+ ...base,
347
+ passed: false,
348
+ version: "check failed",
349
+ hint: "Check repository structure",
350
+ };
243
351
  }
244
352
  }
245
353
 
@@ -4,7 +4,6 @@ export {
4
4
  } from "./secrets-service.js";
5
5
  export type {
6
6
  Result,
7
- WranglerResult,
8
7
  SecretStatus,
9
8
  SecretCheckResult,
10
9
  WorkerSecretConfig,
@@ -2,12 +2,31 @@ import { describe, it, expect, mock } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { Result } from "./types.js";
5
6
  import { SecretsService } from "./secrets-service.js";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Helpers
9
10
  // ---------------------------------------------------------------------------
10
11
 
12
+ /**
13
+ * Narrow a discriminated union Result<T> after checking ok.
14
+ * Calling expect(result.ok).toBe(true) does not narrow the type,
15
+ * so we use this to unwrap the value after asserting ok.
16
+ */
17
+ function expectOk<T>(result: Result<T>): T {
18
+ expect(result.ok).toBe(true);
19
+ return (result as { ok: true; value: T }).value;
20
+ }
21
+
22
+ /**
23
+ * Assert a Result<T> is an error and return the error message.
24
+ */
25
+ function expectErr<T>(result: Result<T>): string {
26
+ expect(result.ok).toBe(false);
27
+ return (result as { ok: false; error: string }).error;
28
+ }
29
+
11
30
  const WORKERS_JSONC = JSON.stringify({
12
31
  global: {},
13
32
  workers: {
@@ -256,8 +275,7 @@ describe("SecretsService", () => {
256
275
  (svc as any).config.workers["telegram-worker"].path = dir;
257
276
 
258
277
  const result = await svc.generateDevVars("telegram-worker");
259
- expect(result.success).toBe(true);
260
- expect(result.data).toBe(join(dir, ".dev.vars"));
278
+ expect(expectOk(result)).toBe(join(dir, ".dev.vars"));
261
279
 
262
280
  // Verify file content
263
281
  const content = await Bun.file(join(dir, ".dev.vars")).text();
@@ -277,8 +295,7 @@ describe("SecretsService", () => {
277
295
  (svc as any).config.workers["no-secrets-worker"].path = dir;
278
296
 
279
297
  const result = await svc.generateDevVars("no-secrets-worker");
280
- expect(result.success).toBe(true);
281
- expect(result.data).toBe(join(dir, ".dev.vars"));
298
+ expect(expectOk(result)).toBe(join(dir, ".dev.vars"));
282
299
 
283
300
  const content = await Bun.file(join(dir, ".dev.vars")).text();
284
301
  expect(content).toBe("");
@@ -290,8 +307,7 @@ describe("SecretsService", () => {
290
307
  it("returns error for unknown worker", async () => {
291
308
  const svc = await createService();
292
309
  const result = await svc.generateDevVars("unknown-worker");
293
- expect(result.success).toBe(false);
294
- expect(result.error).toContain("not found in config");
310
+ expect(expectErr(result)).toContain("not found in config");
295
311
  });
296
312
 
297
313
  it("overwrites existing .dev.vars", async () => {
@@ -341,8 +357,7 @@ describe("SecretsService", () => {
341
357
  );
342
358
 
343
359
  const result = await svc.syncToCloudflare("telegram-worker");
344
- expect(result.success).toBe(true);
345
- expect(result.data).toEqual(["TELEGRAM_BOT_TOKEN"]);
360
+ expect(expectOk(result)).toEqual(["TELEGRAM_BOT_TOKEN"]);
346
361
  expect(called).toEqual([["TELEGRAM_BOT_TOKEN", "my-real-token"]]);
347
362
  } finally {
348
363
  rmSync(dir, { recursive: true, force: true });
@@ -370,11 +385,10 @@ describe("SecretsService", () => {
370
385
  );
371
386
 
372
387
  const result = await svc.syncToCloudflare("trade-worker");
373
- // Only BINANCE_API_KEY was synced
374
- expect(result.success).toBe(false); // partial success = false
375
- expect(result.data).toEqual(["BINANCE_API_KEY"]);
376
- expect(result.error).toContain("API_SERVICE_KEY");
377
- expect(result.error).toContain("BINANCE_API_SECRET");
388
+ const errMsg = expectErr(result);
389
+ // Only BINANCE_API_KEY was synced — but errors exist, so overall result is error
390
+ expect(errMsg).toContain("API_SERVICE_KEY");
391
+ expect(errMsg).toContain("BINANCE_API_SECRET");
378
392
  expect(called).toEqual([["BINANCE_API_KEY", "real-binance-key"]]);
379
393
  } finally {
380
394
  rmSync(dir, { recursive: true, force: true });
@@ -397,11 +411,10 @@ describe("SecretsService", () => {
397
411
  );
398
412
 
399
413
  const result = await svc.syncToCloudflare("trade-worker");
400
- expect(result.success).toBe(false);
401
- expect(result.data).toBeUndefined();
402
- expect(result.error).toContain("API_SERVICE_KEY");
403
- expect(result.error).toContain("BINANCE_API_KEY");
404
- expect(result.error).toContain("BINANCE_API_SECRET");
414
+ const errMsg = expectErr(result);
415
+ expect(errMsg).toContain("API_SERVICE_KEY");
416
+ expect(errMsg).toContain("BINANCE_API_KEY");
417
+ expect(errMsg).toContain("BINANCE_API_SECRET");
405
418
  expect(called).toHaveLength(0);
406
419
  } finally {
407
420
  rmSync(dir, { recursive: true, force: true });
@@ -411,8 +424,7 @@ describe("SecretsService", () => {
411
424
  it("returns error for unknown worker", async () => {
412
425
  const svc = await createService();
413
426
  const result = await svc.syncToCloudflare("unknown-worker");
414
- expect(result.success).toBe(false);
415
- expect(result.error).toContain("not found in config");
427
+ expect(expectErr(result)).toContain("not found in config");
416
428
  });
417
429
 
418
430
  it("handles wrangler failures gracefully", async () => {
@@ -431,9 +443,7 @@ describe("SecretsService", () => {
431
443
  });
432
444
 
433
445
  const result = await svc.syncToCloudflare("telegram-worker");
434
- expect(result.success).toBe(false);
435
- expect(result.data).toBeUndefined();
436
- expect(result.error).toContain("wrangler command failed");
446
+ expect(expectErr(result)).toContain("wrangler command failed");
437
447
  } finally {
438
448
  rmSync(dir, { recursive: true, force: true });
439
449
  }
@@ -447,8 +457,7 @@ describe("SecretsService", () => {
447
457
  (svc as any).config.workers["no-secrets-worker"].path = dir;
448
458
 
449
459
  const result = await svc.syncToCloudflare("no-secrets-worker");
450
- expect(result.success).toBe(true);
451
- expect(result.data).toEqual([]);
460
+ expect(expectOk(result)).toEqual([]);
452
461
  } finally {
453
462
  rmSync(dir, { recursive: true, force: true });
454
463
  }