hub-launch 1.24.0 → 1.26.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 (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +59 -0
  3. package/dist/commands/approve.d.ts.map +1 -1
  4. package/dist/commands/approve.js +15 -0
  5. package/dist/commands/approve.js.map +1 -1
  6. package/dist/commands/errorWatcher.d.ts +147 -0
  7. package/dist/commands/errorWatcher.d.ts.map +1 -0
  8. package/dist/commands/errorWatcher.fixture.d.ts +32 -0
  9. package/dist/commands/errorWatcher.fixture.d.ts.map +1 -0
  10. package/dist/commands/errorWatcher.fixture.js +32 -0
  11. package/dist/commands/errorWatcher.fixture.js.map +1 -0
  12. package/dist/commands/errorWatcher.js +1017 -0
  13. package/dist/commands/errorWatcher.js.map +1 -0
  14. package/dist/commands/init.d.ts +12 -2
  15. package/dist/commands/init.d.ts.map +1 -1
  16. package/dist/commands/init.js +85 -217
  17. package/dist/commands/init.js.map +1 -1
  18. package/dist/commands/launch.d.ts +15 -0
  19. package/dist/commands/launch.d.ts.map +1 -1
  20. package/dist/commands/launch.js +73 -0
  21. package/dist/commands/launch.js.map +1 -1
  22. package/dist/commands/preview.js +4 -16
  23. package/dist/commands/preview.js.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/services/group/GroupLaunchService.d.ts +6 -6
  27. package/dist/services/hooks/HookExecutor.d.ts +24 -0
  28. package/dist/services/hooks/HookExecutor.d.ts.map +1 -1
  29. package/dist/services/hooks/HookExecutor.js +61 -3
  30. package/dist/services/hooks/HookExecutor.js.map +1 -1
  31. package/dist/types/config.schema.d.ts +11 -8
  32. package/dist/types/config.schema.d.ts.map +1 -1
  33. package/dist/types/config.schema.js +5 -1
  34. package/dist/types/config.schema.js.map +1 -1
  35. package/dist/types/errorWatcher.schema.d.ts +72 -0
  36. package/dist/types/errorWatcher.schema.d.ts.map +1 -0
  37. package/dist/types/errorWatcher.schema.js +18 -0
  38. package/dist/types/errorWatcher.schema.js.map +1 -0
  39. package/dist/types/index.d.ts +1 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/dist/types/index.js +1 -0
  42. package/dist/types/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,1017 @@
1
+ import axios from 'axios';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { logger } from '../utils/logger.js';
5
+ import { resolveProject } from '../utils/project-resolver.js';
6
+ import { loadGitHubToken } from './login.js';
7
+ import { HULA_PROJECT_URL } from '../config/constants.js';
8
+ import { resolveProviderCredentials } from '../utils/provider-credentials.js';
9
+ // ── Tuning bounds (client-side convenience checks; the server is authoritative) ──
10
+ const DEDUPE_WINDOW_MIN = 300;
11
+ const DEDUPE_WINDOW_MAX = 604800;
12
+ const OUTCOME_TYPES = ['pr', 'plan', 'feedback'];
13
+ const PR_POLICIES = ['always', 'skip-if-open', 'close-previous'];
14
+ const CONTAINER_BOUNDS = {
15
+ cpu: { min: 1, max: 32 },
16
+ memory: { min: 1, max: 128 },
17
+ disk: { min: 5, max: 200 },
18
+ };
19
+ // Server-side caps on the watcher fields that shape what the fix agent sees.
20
+ // Mirrors WATCHER_INSTRUCTIONS_MAX_LEN / WATCHER_SECRET*_LEN /
21
+ // WATCHER_SECRETS_MAX_COUNT / ERROR_ENV_VARS_MAX_COUNT in @hula/shared. The
22
+ // server re-validates; these only save a round trip.
23
+ const INSTRUCTIONS_MAX_LEN = 4000;
24
+ const SECRET_MIN_LEN = 6;
25
+ const SECRET_MAX_LEN = 512;
26
+ const SECRETS_MAX_COUNT = 50;
27
+ const ERROR_ENV_VARS_MAX_COUNT = 50;
28
+ const ERROR_ENV_VAR_VALUE_MAX_LEN = 8192;
29
+ /**
30
+ * Env var names the server refuses on `errorEnvVars` because the platform owns
31
+ * them inside the fix sandbox. Rejecting client-side turns a 400 into an
32
+ * immediate, specific message.
33
+ */
34
+ const RESERVED_ENV_PREFIXES = ['HULA_', 'RALPH_'];
35
+ const RESERVED_ENV_NAMES = ['GITHUB_TOKEN', 'PATH', 'HOME'];
36
+ /**
37
+ * Fields the create endpoint accepts but `PATCH /api/v1/error-watchers/:id`
38
+ * does not. Sending them on `--update` used to succeed while silently changing
39
+ * nothing, so they are rejected up front instead.
40
+ */
41
+ const CREATE_ONLY_FLAGS = [
42
+ ['containerCpu', '--container-cpu'],
43
+ ['containerMemory', '--container-memory'],
44
+ ['containerDisk', '--container-disk'],
45
+ ['updateNotificationUrl', '--update-notification-url'],
46
+ ['updateNotificationNameTag', '--update-notification-name-tag'],
47
+ ];
48
+ /**
49
+ * Manage production Error Watchers — thin REST client over
50
+ * `/api/v1/error-watchers`, structured like `hula schedule`.
51
+ */
52
+ export function errorWatcherCommand(program, config) {
53
+ program
54
+ .command('error-watcher')
55
+ .description('Manage production Error Watchers (inbound error webhooks that auto-launch fix PRs)')
56
+ .option('--create', 'Create a new error watcher for this project')
57
+ .option('--list', 'List error watchers for this project')
58
+ .option('--show <watcherId>', 'Show one watcher configuration')
59
+ .option('--events [watcherId]', 'List recent error events (all watchers if no id)')
60
+ .option('--update <watcherId>', 'Update watcher settings')
61
+ .option('--delete <watcherId>', 'Delete (soft) a watcher')
62
+ .option('--print-setup [watcherId]', 'Re-print the .env block and request snippet (no secret values)')
63
+ // Create/update tuning flags
64
+ .option('--name <name>', 'Watcher label, e.g. api-prod (default: "default")')
65
+ .option('--dedupe-window <seconds>', 'Dedupe window in seconds (300–604800, default 86400)', parseInt)
66
+ .option('--max-fixes-per-day <n>', 'Max fix launches per UTC day (default 5)', parseInt)
67
+ .option('--environments <list>', 'Comma-separated environments allowed to launch fixes (default: production)')
68
+ .option('--outcome-type <type>', 'pr (default) | plan | feedback')
69
+ .option('--pr-policy <policy>', 'always | skip-if-open (default) | close-previous')
70
+ .option('--enabled <bool>', 'Enable or disable the watcher (true|false)')
71
+ .option('--instructions <text>', `Trusted guidance for the fix agent (max ${INSTRUCTIONS_MAX_LEN} chars)`)
72
+ .option('--clear-instructions', 'Remove the stored instructions (--update only)')
73
+ .option('--error-env <KEY=VALUE>', 'Env var for the fix sandbox; repeatable. Replaces the whole map', collectRepeatable)
74
+ .option('--clear-error-env', 'Remove all errorEnvVars (--update only)')
75
+ .option('--secret <value>', 'Extra literal value to redact from payloads; repeatable. Replaces the whole list', collectRepeatable)
76
+ .option('--clear-secrets', 'Remove all extra redaction secrets (--update only)')
77
+ // Filters
78
+ .option('--status <status>', 'Filter --events by status')
79
+ .option('--limit <n>', 'Max rows for --list/--events (default 20)', parseInt)
80
+ // Standard overrides, identical to `hula schedule`
81
+ .option('--provider <type>', 'LLM provider: claude | openai | openrouter')
82
+ .option('--provider-key <key>', 'Provider credential')
83
+ .option('--container-cpu <n>', 'vCPU for fix sandboxes (1–32)', parseInt)
84
+ .option('--container-memory <n>', 'GiB RAM for fix sandboxes (1–128)', parseInt)
85
+ .option('--container-disk <n>', 'GiB disk for fix sandboxes (5–200)', parseInt)
86
+ .option('--update-notification-url <url>', 'Webhook for fix-run notifications')
87
+ .option('--update-notification-name-tag <tag>', 'Verbatim "initiated by" label for notifications')
88
+ .option('--project <owner/repo>', 'Override the target repository')
89
+ .option('--url <url>', 'Override hula-project server URL')
90
+ .option('--api-key <key>', 'Override hula API key')
91
+ .option('--yes', 'Skip the interactive confirmation on --delete')
92
+ .option('--json', 'Emit raw JSON instead of formatted output')
93
+ .addHelpText('after', `
94
+ An Error Watcher is an inbound webhook: your production app reports an error to
95
+ HubLaunch, and HubLaunch deduplicates it and auto-launches a fix (a PR, plan, or
96
+ feedback run). This command creates and manages those watchers from the CLI.
97
+
98
+ The watcher holds the CONFIGURATION (dedupe window, environments, PR policy,
99
+ instructions, errorEnvVars, secrets). The CREDENTIAL your app presents is a
100
+ separate project ingest key (hik_…) with its own signing secret (his_…), created
101
+ in the dashboard under Projects → gear icon → Error reporting API keys. Both are
102
+ required: a valid key whose project has no enabled watcher is rejected with 409.
103
+
104
+ Examples:
105
+ hula error-watcher --create --name api-prod
106
+ Create a watcher, then print the .env block and a ready-to-paste
107
+ signed-request snippet.
108
+
109
+ hula error-watcher --events
110
+ Show recent error events across every watcher on this project, and why
111
+ each did or did not launch a fix (deduped / skipped_budget /
112
+ skipped_credits / skipped_environment / …).
113
+
114
+ hula error-watcher --update <id> --instructions "Never touch billing/"
115
+ Set the trusted guidance handed to the fix agent.
116
+
117
+ Credentials resolve from flags, then .hublaunch/hublaunch.config.js, then env,
118
+ exactly like "hula schedule". Run "hula login" first.
119
+
120
+ Docs: https://github.com/NoStackApp/hub-launch/blob/main/docs/error-watcher.md
121
+ `)
122
+ .action(async (options, command) => {
123
+ try {
124
+ await runErrorWatcher(config, options, () => command.help());
125
+ }
126
+ catch (error) {
127
+ logger.error('Error watcher command failed:');
128
+ logger.error(error instanceof Error ? error.message : String(error));
129
+ process.exit(1);
130
+ }
131
+ });
132
+ }
133
+ /**
134
+ * Commander collector for a repeatable flag. `previous` is undefined on the
135
+ * first occurrence, which is what lets a caller distinguish "flag omitted"
136
+ * (undefined) from "flag given" — the distinction PATCH relies on to leave a
137
+ * field untouched rather than clearing it.
138
+ */
139
+ export function collectRepeatable(value, previous) {
140
+ return [...(previous ?? []), value];
141
+ }
142
+ /**
143
+ * Which mode flags are active. `--events` and `--print-setup` count when
144
+ * present even without a value (`true`); every other flag counts when truthy.
145
+ */
146
+ export function selectModes(options) {
147
+ const modes = [];
148
+ if (options.create)
149
+ modes.push('create');
150
+ if (options.list)
151
+ modes.push('list');
152
+ if (options.show)
153
+ modes.push('show');
154
+ if (options.events !== undefined)
155
+ modes.push('events');
156
+ if (options.update)
157
+ modes.push('update');
158
+ if (options.delete)
159
+ modes.push('delete');
160
+ if (options.printSetup !== undefined)
161
+ modes.push('print-setup');
162
+ return modes;
163
+ }
164
+ /**
165
+ * Dispatch a single mode. Enforces mutual exclusion BEFORE any network call, so
166
+ * two mode flags fail without issuing a request. With no mode flag, prints help.
167
+ */
168
+ export async function runErrorWatcher(config, options, showHelp) {
169
+ const modes = selectModes(options);
170
+ if (modes.length > 1) {
171
+ logger.error(`Provide only one mode flag — got conflicting: ${modes
172
+ .map((m) => `--${m}`)
173
+ .join(', ')}.`);
174
+ process.exit(1);
175
+ return;
176
+ }
177
+ if (modes.length === 0) {
178
+ if (showHelp) {
179
+ showHelp();
180
+ }
181
+ else {
182
+ logger.info('No mode flag given. Run "hula error-watcher --help" for usage.');
183
+ }
184
+ return;
185
+ }
186
+ switch (modes[0]) {
187
+ case 'create':
188
+ return createWatcher(config, options);
189
+ case 'list':
190
+ return listWatchers(config, options);
191
+ case 'show':
192
+ return showWatcher(config, options.show, options);
193
+ case 'events':
194
+ return listEvents(config, options);
195
+ case 'update':
196
+ return updateWatcher(config, options.update, options);
197
+ case 'delete':
198
+ return deleteWatcher(config, options.delete, options);
199
+ case 'print-setup':
200
+ return printSetupOnly(config, options);
201
+ }
202
+ }
203
+ // ── Resolution helpers (parity with schedule.ts) ────────────────────────────
204
+ function resolveServerUrl(config, options) {
205
+ const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
206
+ return serverUrl.replace(/\/$/, '');
207
+ }
208
+ function resolveApiKey(config, options) {
209
+ const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
210
+ if (!apiKey) {
211
+ logger.error('No API key configured.');
212
+ logger.info('Set hulaApiKey in your config or use --api-key option.');
213
+ logger.info('You can also set HULA_API_KEY environment variable.');
214
+ logger.info("Run 'hula login' to authenticate with hula-project.");
215
+ process.exit(1);
216
+ }
217
+ return apiKey;
218
+ }
219
+ function authHeaders(apiKey) {
220
+ return {
221
+ 'Content-Type': 'application/json',
222
+ Authorization: `Bearer ${apiKey}`,
223
+ };
224
+ }
225
+ /**
226
+ * The ingest webhook URL the customer's production app POSTs to. Defaults to
227
+ * `<serverUrl>/api/v1/webhooks/error`, overridable via `config.errorWebhookUrl`
228
+ * for self-hosted servers whose webhook host differs from `hulaProjectUrl`.
229
+ */
230
+ export function resolveWebhookUrl(serverUrl, config) {
231
+ return config.errorWebhookUrl || `${serverUrl}/api/v1/webhooks/error`;
232
+ }
233
+ /**
234
+ * Where a project ingest key (`hik_…`) is minted. Key CRUD is exposed over
235
+ * tRPC only — there is no REST route for the CLI to call — so every setup path
236
+ * points the user at this page rather than pretending to issue a credential.
237
+ */
238
+ export function resolveProjectSettingsUrl(serverUrl) {
239
+ return `${serverUrl}/dashboard/projects`;
240
+ }
241
+ /** The three lines that direct a user to mint an ingest key. */
242
+ function printIngestKeyInstructions(serverUrl) {
243
+ logger.info(` Open ${resolveProjectSettingsUrl(serverUrl)}`);
244
+ logger.info(' Click the gear icon on the project card → Error reporting API keys → Create key.');
245
+ logger.info(' Copy the hik_… key and his_… secret — both are shown exactly once.');
246
+ }
247
+ // ── Client-side validation ──────────────────────────────────────────────────
248
+ /**
249
+ * Validate tuning flags client-side so the common mistakes are caught without a
250
+ * network round trip. Returns a list of error strings (empty when all valid).
251
+ * The server validates independently; this is a convenience, not the boundary.
252
+ */
253
+ export function validateTuningOptions(options) {
254
+ const errors = [];
255
+ if (options.dedupeWindow !== undefined) {
256
+ const w = options.dedupeWindow;
257
+ if (!Number.isInteger(w) ||
258
+ w < DEDUPE_WINDOW_MIN ||
259
+ w > DEDUPE_WINDOW_MAX) {
260
+ errors.push(`--dedupe-window must be an integer between ${DEDUPE_WINDOW_MIN} and ${DEDUPE_WINDOW_MAX} seconds.`);
261
+ }
262
+ }
263
+ if (options.maxFixesPerDay !== undefined) {
264
+ const n = options.maxFixesPerDay;
265
+ if (!Number.isInteger(n) || n < 1) {
266
+ errors.push('--max-fixes-per-day must be an integer of at least 1.');
267
+ }
268
+ }
269
+ if (options.outcomeType !== undefined &&
270
+ !OUTCOME_TYPES.includes(options.outcomeType)) {
271
+ errors.push(`--outcome-type must be one of: ${OUTCOME_TYPES.join(', ')}.`);
272
+ }
273
+ if (options.prPolicy !== undefined &&
274
+ !PR_POLICIES.includes(options.prPolicy)) {
275
+ errors.push(`--pr-policy must be one of: ${PR_POLICIES.join(', ')}.`);
276
+ }
277
+ if (options.enabled !== undefined &&
278
+ options.enabled !== 'true' &&
279
+ options.enabled !== 'false') {
280
+ errors.push('--enabled must be "true" or "false".');
281
+ }
282
+ for (const [key, flag] of [
283
+ ['cpu', 'containerCpu'],
284
+ ['memory', 'containerMemory'],
285
+ ['disk', 'containerDisk'],
286
+ ]) {
287
+ const value = options[flag];
288
+ if (value === undefined)
289
+ continue;
290
+ const { min, max } = CONTAINER_BOUNDS[key];
291
+ if (!Number.isInteger(value) || value < min || value > max) {
292
+ errors.push(`--container-${key} must be an integer between ${min} and ${max}.`);
293
+ }
294
+ }
295
+ if (options.instructions !== undefined &&
296
+ options.instructions.length > INSTRUCTIONS_MAX_LEN) {
297
+ errors.push(`--instructions must be at most ${INSTRUCTIONS_MAX_LEN} characters (got ${options.instructions.length}).`);
298
+ }
299
+ if (options.instructions !== undefined && options.clearInstructions) {
300
+ errors.push('--instructions and --clear-instructions are mutually exclusive.');
301
+ }
302
+ if (options.errorEnv !== undefined && options.clearErrorEnv) {
303
+ errors.push('--error-env and --clear-error-env are mutually exclusive.');
304
+ }
305
+ if (options.secret !== undefined && options.clearSecrets) {
306
+ errors.push('--secret and --clear-secrets are mutually exclusive.');
307
+ }
308
+ errors.push(...validateErrorEnv(options.errorEnv));
309
+ errors.push(...validateSecrets(options.secret));
310
+ return errors;
311
+ }
312
+ /**
313
+ * Validate the repeatable `--error-env KEY=VALUE` pairs. Reserved names are
314
+ * rejected here because the server returns a 400 for them, and the platform
315
+ * owns those variables inside the fix sandbox.
316
+ */
317
+ export function validateErrorEnv(entries) {
318
+ if (entries === undefined)
319
+ return [];
320
+ const errors = [];
321
+ if (entries.length > ERROR_ENV_VARS_MAX_COUNT) {
322
+ errors.push(`--error-env may be given at most ${ERROR_ENV_VARS_MAX_COUNT} times (got ${entries.length}).`);
323
+ }
324
+ const seen = new Set();
325
+ for (const entry of entries) {
326
+ const eq = entry.indexOf('=');
327
+ if (eq <= 0) {
328
+ errors.push(`--error-env "${entry}" must be in KEY=VALUE form.`);
329
+ continue;
330
+ }
331
+ const key = entry.slice(0, eq);
332
+ const value = entry.slice(eq + 1);
333
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
334
+ errors.push(`--error-env key "${key}" is not a valid environment variable name.`);
335
+ }
336
+ if (seen.has(key)) {
337
+ errors.push(`--error-env key "${key}" was given more than once.`);
338
+ }
339
+ seen.add(key);
340
+ if (RESERVED_ENV_PREFIXES.some((p) => key.startsWith(p)) ||
341
+ RESERVED_ENV_NAMES.includes(key)) {
342
+ errors.push(`--error-env key "${key}" is reserved by the platform and will be rejected by the server.`);
343
+ }
344
+ if (value.length > ERROR_ENV_VAR_VALUE_MAX_LEN) {
345
+ errors.push(`--error-env value for "${key}" exceeds ${ERROR_ENV_VAR_VALUE_MAX_LEN} characters.`);
346
+ }
347
+ }
348
+ return errors;
349
+ }
350
+ /**
351
+ * Validate the repeatable `--secret` values. The minimum length is the server's
352
+ * floor: a very short literal would match everywhere and shred the very error
353
+ * text the fix agent needs.
354
+ */
355
+ export function validateSecrets(entries) {
356
+ if (entries === undefined)
357
+ return [];
358
+ const errors = [];
359
+ if (entries.length > SECRETS_MAX_COUNT) {
360
+ errors.push(`--secret may be given at most ${SECRETS_MAX_COUNT} times (got ${entries.length}).`);
361
+ }
362
+ for (const value of entries) {
363
+ if (value.length < SECRET_MIN_LEN || value.length > SECRET_MAX_LEN) {
364
+ // The value itself is a credential — report its length, never its text.
365
+ errors.push(`--secret values must be between ${SECRET_MIN_LEN} and ${SECRET_MAX_LEN} characters (got one of length ${value.length}).`);
366
+ }
367
+ }
368
+ return errors;
369
+ }
370
+ /** Parse validated `KEY=VALUE` entries into the map the server expects. */
371
+ export function parseErrorEnv(entries) {
372
+ const map = {};
373
+ for (const entry of entries) {
374
+ const eq = entry.indexOf('=');
375
+ if (eq <= 0)
376
+ continue;
377
+ map[entry.slice(0, eq)] = entry.slice(eq + 1);
378
+ }
379
+ return map;
380
+ }
381
+ /**
382
+ * Build the subset of tuning fields the user actually supplied. Used by both
383
+ * `--create` and `--update` so a PATCH never carries `undefined` keys.
384
+ *
385
+ * `mode` gates the fields the create endpoint accepts but PATCH ignores
386
+ * (container resources, notification overrides). Sending them on an update
387
+ * used to return 200 while changing nothing; `--update` now rejects them in
388
+ * `updateWatcher` before the request, and they are omitted here as a backstop.
389
+ */
390
+ function collectTuningFields(options, mode = 'create') {
391
+ const body = {};
392
+ if (options.name !== undefined)
393
+ body.name = options.name;
394
+ if (options.dedupeWindow !== undefined) {
395
+ body.dedupeWindowSeconds = options.dedupeWindow;
396
+ }
397
+ if (options.maxFixesPerDay !== undefined) {
398
+ body.maxFixesPerDay = options.maxFixesPerDay;
399
+ }
400
+ if (options.environments !== undefined) {
401
+ body.environments = options.environments
402
+ .split(',')
403
+ .map((e) => e.trim())
404
+ .filter((e) => e.length > 0);
405
+ }
406
+ if (options.outcomeType !== undefined)
407
+ body.outcomeType = options.outcomeType;
408
+ if (options.prPolicy !== undefined)
409
+ body.prPolicy = options.prPolicy;
410
+ if (options.enabled !== undefined)
411
+ body.enabled = options.enabled === 'true';
412
+ // Trusted guidance for the fix agent. `null` clears it; omitting the key
413
+ // leaves the stored value untouched — the server distinguishes the two.
414
+ if (options.instructions !== undefined) {
415
+ body.instructions = options.instructions;
416
+ }
417
+ else if (options.clearInstructions) {
418
+ body.instructions = null;
419
+ }
420
+ // Both of these REPLACE the stored value wholesale on the server, so the CLI
421
+ // sends the complete set the user typed, never a delta.
422
+ if (options.errorEnv !== undefined) {
423
+ body.errorEnvVars = parseErrorEnv(options.errorEnv);
424
+ }
425
+ else if (options.clearErrorEnv) {
426
+ body.errorEnvVars = {};
427
+ }
428
+ if (options.secret !== undefined) {
429
+ body.secrets = options.secret;
430
+ }
431
+ else if (options.clearSecrets) {
432
+ body.secrets = [];
433
+ }
434
+ if (mode === 'create') {
435
+ const cpu = options.containerCpu;
436
+ const memory = options.containerMemory;
437
+ const disk = options.containerDisk;
438
+ if (cpu !== undefined || memory !== undefined || disk !== undefined) {
439
+ const resources = {};
440
+ if (cpu !== undefined)
441
+ resources.cpu = cpu;
442
+ if (memory !== undefined)
443
+ resources.memory = memory;
444
+ if (disk !== undefined)
445
+ resources.disk = disk;
446
+ body.containerResources = resources;
447
+ }
448
+ if (options.updateNotificationUrl !== undefined) {
449
+ body.updateNotificationUrl = options.updateNotificationUrl;
450
+ }
451
+ if (options.updateNotificationNameTag !== undefined) {
452
+ body.updateNotificationNameTag = options.updateNotificationNameTag;
453
+ }
454
+ }
455
+ return body;
456
+ }
457
+ // ── Setup output (the core value of this command) ───────────────────────────
458
+ /**
459
+ * The signed-request snippet. HARD-CODED (never fetched) so `--print-setup`
460
+ * works with the server unreachable. Built line-by-line from single/double
461
+ * quoted strings so the literal `${timestamp}.${body}` signing payload survives
462
+ * verbatim into the printed output (it must NOT be interpolated here).
463
+ */
464
+ export function buildSigningSnippet() {
465
+ return [
466
+ '// Report an error to HubLaunch. Call this from your error handler.',
467
+ "import { createHmac } from 'node:crypto';",
468
+ '',
469
+ 'export async function reportErrorToHula(err, extra = {}) {',
470
+ ' const body = JSON.stringify({',
471
+ ' errorDescription: err?.message ?? String(err),',
472
+ ' // ── Dedupe identity. The fingerprint is built from these three fields',
473
+ ' // and ONLY these three. Send none of them and every error on this',
474
+ ' // watcher collapses into one bucket — one fix per dedupe window.',
475
+ ' key: extra.key, // your own stable key; survives refactors',
476
+ ' errorName: err?.name, // e.g. TypeError',
477
+ ' errorCode: err?.code, // e.g. P2002',
478
+ ' stack: err?.stack,',
479
+ ' logs: extra.logs,',
480
+ " environment: process.env.NODE_ENV ?? 'production',",
481
+ ' release: process.env.GIT_COMMIT_SHA, // enables fixing the exact shipped code',
482
+ ' occurredAt: new Date().toISOString(),',
483
+ ' context: extra.context,',
484
+ ' });',
485
+ '',
486
+ ' const timestamp = Math.floor(Date.now() / 1000).toString();',
487
+ " const signature = createHmac('sha256', process.env.HULA_INGEST_SECRET)",
488
+ ' .update(`${timestamp}.${body}`) // NOTE: timestamp + "." + body',
489
+ " .digest('hex');",
490
+ '',
491
+ ' try {',
492
+ ' await fetch(process.env.HULA_ERROR_WEBHOOK_URL, {',
493
+ " method: 'POST',",
494
+ ' headers: {',
495
+ " 'Content-Type': 'application/json',",
496
+ " 'X-Hula-Api-Key': process.env.HULA_INGEST_KEY,",
497
+ " 'X-Hula-Timestamp': timestamp,",
498
+ " 'X-Hula-Signature': `sha256=${signature}`,",
499
+ ' },',
500
+ ' body,',
501
+ ' });',
502
+ ' } catch {',
503
+ ' // Never let error reporting break the request that was already failing.',
504
+ ' }',
505
+ '}',
506
+ ].join('\n');
507
+ }
508
+ /**
509
+ * The `.env` block for the customer's production environment.
510
+ *
511
+ * The credential is the PROJECT ingest key (`hik_…`) and its own signing secret
512
+ * (`his_…`), minted in the dashboard settings page — not the watcher. The CLI
513
+ * never holds either value, so both are always placeholders.
514
+ */
515
+ export function buildEnvBlock(webhookUrl) {
516
+ return [
517
+ '# HubLaunch Error Watcher — add to your production environment',
518
+ `HULA_ERROR_WEBHOOK_URL=${webhookUrl}`,
519
+ 'HULA_INGEST_KEY=hik_…',
520
+ 'HULA_INGEST_SECRET=his_…',
521
+ ].join('\n');
522
+ }
523
+ /** The notes printed below the snippet. */
524
+ function printSnippetNotes() {
525
+ logger.blank();
526
+ logger.info('Notes:');
527
+ logger.info(' • Send `key`, `errorName` or `errorCode`. The dedupe fingerprint is built');
528
+ logger.info(' from those three fields alone — with none of them, every error on this');
529
+ logger.info(' watcher shares one bucket and you get one fix per window.');
530
+ logger.info(' • Do not log credentials. The server best-effort redacts common secret');
531
+ logger.info(' shapes from stack/logs on arrival, but the real fix is not printing them.');
532
+ logger.info(' Use --secret to register literals the built-in matcher cannot know about.');
533
+ logger.info(' • Set `release` to your deployed commit SHA so the fix agent branches from');
534
+ logger.info(' the code that actually crashed, not your default branch.');
535
+ logger.info(' • A 409 means the key is valid but this project has no enabled watcher.');
536
+ }
537
+ /**
538
+ * Print the wiring steps for a watcher: mint the project ingest key, set the
539
+ * env vars, paste the signed-request snippet.
540
+ *
541
+ * The CLI deliberately prints no credential of its own. Ingest keys are created
542
+ * over tRPC from the dashboard and have no REST route, so the only honest thing
543
+ * the CLI can do is say where to get one. The watcher `hew_`/`hes_` values the
544
+ * create endpoint still returns authenticate nothing and are never shown.
545
+ */
546
+ export function printSetupInstructions(serverUrl, webhookUrl) {
547
+ logger.blank();
548
+ logger.info('① Create a project ingest key (the credential your app presents):');
549
+ printIngestKeyInstructions(serverUrl);
550
+ logger.blank();
551
+ logger.info('② Add these to your production environment:');
552
+ logger.blank();
553
+ logger.log(buildEnvBlock(webhookUrl));
554
+ logger.blank();
555
+ logger.info('③ Report errors with this snippet (signing is not guessable):');
556
+ logger.blank();
557
+ logger.log(buildSigningSnippet());
558
+ printSnippetNotes();
559
+ logger.blank();
560
+ }
561
+ // ── Mode: create ────────────────────────────────────────────────────────────
562
+ async function createWatcher(config, options) {
563
+ logger.section('Create Error Watcher');
564
+ const validationErrors = validateTuningOptions(options);
565
+ if (validationErrors.length > 0) {
566
+ validationErrors.forEach((msg) => logger.error(msg));
567
+ process.exit(1);
568
+ return;
569
+ }
570
+ const serverUrl = resolveServerUrl(config, options);
571
+ const apiKey = resolveApiKey(config, options);
572
+ const project = await resolveProject(config, options.project);
573
+ // A fix run cannot launch without a provider credential, so the server rejects
574
+ // a watcher created with none. Catch it client-side before the request.
575
+ const { providerType, authToken, errors } = resolveProviderCredentials(options, config);
576
+ if (errors.length > 0) {
577
+ errors.forEach((msg) => logger.error(msg));
578
+ process.exit(1);
579
+ return;
580
+ }
581
+ const requestBody = {
582
+ project,
583
+ provider: { type: providerType, authToken },
584
+ ...collectTuningFields(options),
585
+ };
586
+ // The server stores an encrypted GitHub token on the watcher for use at
587
+ // fix-launch time. Attach when present; if absent the server returns its own
588
+ // error (parity with `hula schedule`).
589
+ const tokenData = await loadGitHubToken();
590
+ const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
591
+ if (githubToken) {
592
+ requestBody.githubToken = githubToken;
593
+ }
594
+ logger.info(`Project: ${project}`);
595
+ logger.info(`Server: ${serverUrl}`);
596
+ logger.blank();
597
+ try {
598
+ // The response also carries a `credentials` object holding the watcher's
599
+ // `hew_`/`hes_` pair. Nothing verifies those any more — ingest authenticates
600
+ // on the project key — so they are deliberately not destructured, not
601
+ // printed, and not stored.
602
+ const response = await axios.post(`${serverUrl}/api/v1/error-watchers`, requestBody, { headers: authHeaders(apiKey), timeout: 30000 });
603
+ const { watcher } = response.data;
604
+ if (options.json) {
605
+ logger.log(JSON.stringify({ watcher }, null, 2));
606
+ return;
607
+ }
608
+ logger.success(`Error watcher "${watcher.name}" created (id: ${watcher.id}).`);
609
+ printSetupInstructions(serverUrl, resolveWebhookUrl(serverUrl, config));
610
+ }
611
+ catch (error) {
612
+ handleWatcherApiError(error, serverUrl, { project });
613
+ process.exit(1);
614
+ }
615
+ }
616
+ // ── Mode: list ──────────────────────────────────────────────────────────────
617
+ async function listWatchers(config, options) {
618
+ const serverUrl = resolveServerUrl(config, options);
619
+ const apiKey = resolveApiKey(config, options);
620
+ const project = await resolveProject(config, options.project);
621
+ try {
622
+ const response = await axios.get(`${serverUrl}/api/v1/error-watchers`, {
623
+ params: { project, limit: options.limit },
624
+ headers: authHeaders(apiKey),
625
+ timeout: 15000,
626
+ });
627
+ if (options.json) {
628
+ logger.log(JSON.stringify(response.data, null, 2));
629
+ return;
630
+ }
631
+ const watchers = response.data.watchers ?? [];
632
+ logger.section(`Error Watchers — ${project}`);
633
+ if (watchers.length === 0) {
634
+ logger.info('No error watchers found. Create one with --create.');
635
+ return;
636
+ }
637
+ for (const w of watchers) {
638
+ logger.log(` ${w.id} ${w.name.padEnd(16)} ${w.environments
639
+ .join(',')
640
+ .padEnd(20)} dedupe:${humanizeDuration(w.dedupeWindowSeconds).padEnd(5)} max/day:${String(w.maxFixesPerDay).padEnd(3)} ${w.enabled ? 'enabled ' : 'disabled'} ${w.lastEventAt ? relativeTime(w.lastEventAt) : 'no events'}`);
641
+ }
642
+ logger.blank();
643
+ }
644
+ catch (error) {
645
+ handleWatcherApiError(error, serverUrl, { project });
646
+ process.exit(1);
647
+ }
648
+ }
649
+ // ── Mode: show ──────────────────────────────────────────────────────────────
650
+ async function showWatcher(config, watcherId, options) {
651
+ const serverUrl = resolveServerUrl(config, options);
652
+ const apiKey = resolveApiKey(config, options);
653
+ const project = await resolveProject(config, options.project);
654
+ try {
655
+ const response = await axios.get(`${serverUrl}/api/v1/error-watchers/${encodeURIComponent(watcherId)}`, { headers: authHeaders(apiKey), timeout: 15000 });
656
+ if (options.json) {
657
+ logger.log(JSON.stringify(response.data, null, 2));
658
+ return;
659
+ }
660
+ const w = response.data.watcher;
661
+ logger.section(`Error Watcher: ${w.name}`);
662
+ logger.log(` ID: ${w.id}`);
663
+ logger.log(` Project: ${w.projectId}`);
664
+ logger.log(` Dedupe window: ${humanizeDuration(w.dedupeWindowSeconds)} (${w.dedupeWindowSeconds}s)`);
665
+ logger.log(` Max fixes/day: ${w.maxFixesPerDay}`);
666
+ logger.log(` Environments: ${w.environments.join(', ')}`);
667
+ logger.log(` Outcome type: ${w.outcomeType}`);
668
+ logger.log(` PR policy: ${w.prPolicy}`);
669
+ logger.log(` Enabled: ${w.enabled}`);
670
+ logger.log(` Instructions: ${summarizeInstructions(w.instructions)}`);
671
+ logger.log(` Created: ${w.createdAt}`);
672
+ logger.log(` Last event: ${w.lastEventAt ?? 'never'}`);
673
+ logger.blank();
674
+ // `errorEnvVars` and `secrets` are encrypted at rest and never echoed by
675
+ // the API, so there is nothing to display for them beyond that fact.
676
+ logger.info('errorEnvVars and secrets are never returned by the API. Replace them wholesale');
677
+ logger.info(` hula error-watcher --update ${w.id} --error-env KEY=VALUE --secret <literal>`);
678
+ logger.blank();
679
+ logger.info('Signing secrets belong to the project ingest key, not the watcher. Rotate at:');
680
+ logger.info(` ${resolveProjectSettingsUrl(serverUrl)} → gear icon → Error reporting API keys`);
681
+ logger.blank();
682
+ }
683
+ catch (error) {
684
+ handleWatcherApiError(error, serverUrl, { project, watcherId });
685
+ process.exit(1);
686
+ }
687
+ }
688
+ // ── Mode: events ────────────────────────────────────────────────────────────
689
+ async function listEvents(config, options) {
690
+ const serverUrl = resolveServerUrl(config, options);
691
+ const apiKey = resolveApiKey(config, options);
692
+ const project = await resolveProject(config, options.project);
693
+ // `--events` with no value → every watcher on the project; with a value →
694
+ // that one watcher. There is no project-wide events endpoint on the server —
695
+ // `/api/v1/error-watchers/events` resolves to the `[watcherId]` route and
696
+ // 404s — so the no-id form lists the watchers and fans out over them.
697
+ const watcherId = typeof options.events === 'string' ? options.events : undefined;
698
+ try {
699
+ const targets = watcherId
700
+ ? [{ id: watcherId, name: watcherId }]
701
+ : (await axios.get(`${serverUrl}/api/v1/error-watchers`, {
702
+ params: { project },
703
+ headers: authHeaders(apiKey),
704
+ timeout: 15000,
705
+ })).data.watchers ?? [];
706
+ if (targets.length === 0) {
707
+ if (options.json) {
708
+ logger.log(JSON.stringify({ events: [] }, null, 2));
709
+ return;
710
+ }
711
+ logger.section('Recent Error Events');
712
+ logger.info(`No error watchers on ${project}. Create one with --create.`);
713
+ return;
714
+ }
715
+ const perWatcher = await Promise.all(targets.map(async (w) => {
716
+ const response = await axios.get(`${serverUrl}/api/v1/error-watchers/${encodeURIComponent(w.id)}/events`, {
717
+ params: { status: options.status, limit: options.limit },
718
+ headers: authHeaders(apiKey),
719
+ timeout: 15000,
720
+ });
721
+ return (response.data.events ?? []).map((e) => ({
722
+ ...e,
723
+ watcherName: w.name,
724
+ }));
725
+ }));
726
+ // Newest first across every watcher, then re-apply --limit so it caps the
727
+ // merged list rather than each watcher independently.
728
+ let events = perWatcher
729
+ .flat()
730
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
731
+ if (options.limit !== undefined) {
732
+ events = events.slice(0, options.limit);
733
+ }
734
+ if (options.json) {
735
+ logger.log(JSON.stringify({ events }, null, 2));
736
+ return;
737
+ }
738
+ logger.section(watcherId ? 'Recent Error Events' : `Recent Error Events — ${project}`);
739
+ if (events.length === 0) {
740
+ logger.info('No error events found.');
741
+ return;
742
+ }
743
+ const showWatcherColumn = !watcherId && targets.length > 1;
744
+ for (const e of events) {
745
+ logger.log(` ${e.createdAt} ${e.environment.padEnd(12)} ${e.fingerprint
746
+ .slice(0, 12)
747
+ .padEnd(12)} ×${String(e.occurrenceCount).padEnd(4)} ${colorStatus(e.status)}${e.skipReason ? ` (${e.skipReason})` : ''}${showWatcherColumn ? ` [${e.watcherName}]` : ''}`);
748
+ }
749
+ logger.blank();
750
+ if (showWatcherColumn) {
751
+ logger.info(`(${targets.length} watchers queried)`);
752
+ }
753
+ logger.info('Legend: launched = a fix run started · deduped = same error within the window');
754
+ logger.info(' skipped_environment = env not in the allow-list · skipped_budget = daily cap hit');
755
+ logger.info(' skipped_credits = out of PR credits · skipped_open_pr = an open fix PR already exists');
756
+ logger.blank();
757
+ }
758
+ catch (error) {
759
+ handleWatcherApiError(error, serverUrl, { project, watcherId });
760
+ process.exit(1);
761
+ }
762
+ }
763
+ // ── Mode: update ────────────────────────────────────────────────────────────
764
+ async function updateWatcher(config, watcherId, options) {
765
+ logger.section('Update Error Watcher');
766
+ const validationErrors = validateTuningOptions(options);
767
+ // PATCH silently ignores these, so accepting them would report success while
768
+ // changing nothing. Fail loudly instead.
769
+ const unsupported = CREATE_ONLY_FLAGS.filter(([key]) => options[key] !== undefined).map(([, flag]) => flag);
770
+ if (unsupported.length > 0) {
771
+ validationErrors.push(`${unsupported.join(', ')} can only be set at --create time; the update endpoint ignores them.`);
772
+ }
773
+ if (validationErrors.length > 0) {
774
+ validationErrors.forEach((msg) => logger.error(msg));
775
+ process.exit(1);
776
+ return;
777
+ }
778
+ const serverUrl = resolveServerUrl(config, options);
779
+ const apiKey = resolveApiKey(config, options);
780
+ const project = await resolveProject(config, options.project);
781
+ const body = collectTuningFields(options, 'update');
782
+ if (Object.keys(body).length === 0) {
783
+ logger.error('Nothing to update — provide at least one field to change.');
784
+ logger.info('e.g. --max-fixes-per-day 10, --enabled false, --environments production,staging,');
785
+ logger.info(' --instructions "…", --error-env KEY=VALUE, --secret <literal>');
786
+ process.exit(1);
787
+ return;
788
+ }
789
+ try {
790
+ const response = await axios.patch(`${serverUrl}/api/v1/error-watchers/${encodeURIComponent(watcherId)}`, body, { headers: authHeaders(apiKey), timeout: 15000 });
791
+ if (options.json) {
792
+ logger.log(JSON.stringify(response.data, null, 2));
793
+ return;
794
+ }
795
+ const w = response.data.watcher;
796
+ logger.success(`Error watcher "${w.name}" updated.`);
797
+ logger.log(` Dedupe window: ${humanizeDuration(w.dedupeWindowSeconds)}`);
798
+ logger.log(` Max fixes/day: ${w.maxFixesPerDay}`);
799
+ logger.log(` Environments: ${w.environments.join(', ')}`);
800
+ logger.log(` Outcome type: ${w.outcomeType}`);
801
+ logger.log(` PR policy: ${w.prPolicy}`);
802
+ logger.log(` Enabled: ${w.enabled}`);
803
+ logger.log(` Instructions: ${summarizeInstructions(w.instructions)}`);
804
+ // errorEnvVars/secrets are encrypted at rest and never echoed back, so the
805
+ // response cannot confirm them beyond the 200 itself.
806
+ if (body.errorEnvVars !== undefined) {
807
+ const count = Object.keys(body.errorEnvVars).length;
808
+ logger.log(` errorEnvVars: replaced (${count} ${count === 1 ? 'entry' : 'entries'}, not echoed back)`);
809
+ }
810
+ if (body.secrets !== undefined) {
811
+ const count = body.secrets.length;
812
+ logger.log(` Secrets: replaced (${count} ${count === 1 ? 'entry' : 'entries'}, not echoed back)`);
813
+ }
814
+ logger.blank();
815
+ }
816
+ catch (error) {
817
+ handleWatcherApiError(error, serverUrl, { project, watcherId });
818
+ process.exit(1);
819
+ }
820
+ }
821
+ // ── Mode: delete ────────────────────────────────────────────────────────────
822
+ async function deleteWatcher(config, watcherId, options) {
823
+ const serverUrl = resolveServerUrl(config, options);
824
+ const apiKey = resolveApiKey(config, options);
825
+ const project = await resolveProject(config, options.project);
826
+ // Look up the name first, so the confirmation names what will break.
827
+ let watcherName = watcherId;
828
+ try {
829
+ const response = await axios.get(`${serverUrl}/api/v1/error-watchers/${encodeURIComponent(watcherId)}`, { headers: authHeaders(apiKey), timeout: 15000 });
830
+ watcherName = response.data.watcher.name;
831
+ }
832
+ catch (error) {
833
+ handleWatcherApiError(error, serverUrl, { project, watcherId });
834
+ process.exit(1);
835
+ return;
836
+ }
837
+ const isTty = Boolean(process.stdin.isTTY);
838
+ if (!options.yes) {
839
+ logger.warning(`Deleting "${watcherName}" (${watcherId}) — if it is the project's only enabled watcher, reports will start returning 409.`);
840
+ if (isTty) {
841
+ const { proceed } = await inquirer.prompt([
842
+ {
843
+ type: 'confirm',
844
+ name: 'proceed',
845
+ message: `Delete error watcher "${watcherName}"?`,
846
+ default: false,
847
+ },
848
+ ]);
849
+ if (!proceed) {
850
+ logger.info('Cancelled — nothing deleted.');
851
+ return;
852
+ }
853
+ }
854
+ else {
855
+ logger.error('Refusing to delete without confirmation. Re-run with --yes in a non-interactive shell.');
856
+ process.exit(1);
857
+ return;
858
+ }
859
+ }
860
+ try {
861
+ await axios.delete(`${serverUrl}/api/v1/error-watchers/${encodeURIComponent(watcherId)}`, { headers: authHeaders(apiKey), timeout: 15000 });
862
+ logger.success(`Error watcher "${watcherName}" deleted.`);
863
+ // The credential is the project ingest key, which is untouched by this
864
+ // delete — it keeps authenticating. What breaks is watcher resolution.
865
+ logger.warning('Your ingest keys still authenticate. If this was the last enabled watcher on');
866
+ logger.warning('the project, reports now return 409 (no enabled error watcher configured).');
867
+ }
868
+ catch (error) {
869
+ handleWatcherApiError(error, serverUrl, { project, watcherId });
870
+ process.exit(1);
871
+ }
872
+ }
873
+ // ── Mode: print-setup ───────────────────────────────────────────────────────
874
+ /**
875
+ * Re-print the `.env` block (with placeholders) and the signing snippet. Makes
876
+ * NO network call — the snippet is hard-coded and no secret is involved — so it
877
+ * works with the server unreachable and is safe to run and share.
878
+ */
879
+ function printSetupOnly(config, options) {
880
+ const serverUrl = resolveServerUrl(config, options);
881
+ const webhookUrl = resolveWebhookUrl(serverUrl, config);
882
+ const watcherId = typeof options.printSetup === 'string' ? options.printSetup : undefined;
883
+ logger.section(watcherId ? `Error Watcher Setup: ${watcherId}` : 'Error Watcher Setup');
884
+ logger.info('No real credential values are shown — the CLI never holds one. The key and');
885
+ logger.info('secret below are placeholders; mint the real pair in the dashboard.');
886
+ printSetupInstructions(serverUrl, webhookUrl);
887
+ }
888
+ // ── Formatting helpers ──────────────────────────────────────────────────────
889
+ /**
890
+ * One-line rendering of the watcher's `instructions`. Long guidance is elided
891
+ * rather than wrapped so the field stays one row in the detail block.
892
+ */
893
+ export function summarizeInstructions(instructions, maxLen = 72) {
894
+ if (!instructions)
895
+ return '(none)';
896
+ const oneLine = instructions.replace(/\s+/g, ' ').trim();
897
+ if (oneLine.length <= maxLen)
898
+ return oneLine;
899
+ return `${oneLine.slice(0, maxLen - 1)}…`;
900
+ }
901
+ /** Humanize a duration in seconds: 300 → 5m, 3600 → 1h, 86400 → 24h, 604800 → 7d. */
902
+ export function humanizeDuration(seconds) {
903
+ if (seconds < 60)
904
+ return `${seconds}s`;
905
+ if (seconds < 3600)
906
+ return formatUnit(seconds / 60, 'm');
907
+ if (seconds < 604800)
908
+ return formatUnit(seconds / 3600, 'h');
909
+ return formatUnit(seconds / 86400, 'd');
910
+ }
911
+ function formatUnit(value, unit) {
912
+ return Number.isInteger(value) ? `${value}${unit}` : `${value.toFixed(1)}${unit}`;
913
+ }
914
+ /** Relative time such as "12m ago" for an ISO timestamp. */
915
+ export function relativeTime(iso) {
916
+ const then = new Date(iso).getTime();
917
+ if (Number.isNaN(then))
918
+ return iso;
919
+ const diffSeconds = Math.max(0, Math.floor((Date.now() - then) / 1000));
920
+ if (diffSeconds < 60)
921
+ return `${diffSeconds}s ago`;
922
+ const minutes = Math.floor(diffSeconds / 60);
923
+ if (minutes < 60)
924
+ return `${minutes}m ago`;
925
+ const hours = Math.floor(minutes / 60);
926
+ if (hours < 24)
927
+ return `${hours}h ago`;
928
+ const days = Math.floor(hours / 24);
929
+ return `${days}d ago`;
930
+ }
931
+ /** Colour an event status: launched green, deduped dim, skipped_* yellow, failed red. */
932
+ function colorStatus(status) {
933
+ if (status === 'launched')
934
+ return chalk.green(status);
935
+ if (status === 'deduped')
936
+ return chalk.dim(status);
937
+ if (status === 'failed')
938
+ return chalk.red(status);
939
+ if (status.startsWith('skipped_'))
940
+ return chalk.yellow(status);
941
+ return status;
942
+ }
943
+ // ── Error mapping ───────────────────────────────────────────────────────────
944
+ /**
945
+ * Map server responses to actionable messages. NEVER echoes the Authorization
946
+ * header, the watcher token, or the secret — none of those are read from the
947
+ * error object, so no redaction pass is needed. Re-throws non-axios errors for
948
+ * the caller's outer handler.
949
+ */
950
+ export function handleWatcherApiError(error, serverUrl, ctx = {}) {
951
+ if (!axios.isAxiosError(error)) {
952
+ throw error;
953
+ }
954
+ if (error.response) {
955
+ const status = error.response.status;
956
+ const data = (error.response.data ?? {});
957
+ switch (status) {
958
+ case 400:
959
+ logger.error(data.error || 'Bad request: invalid parameters.');
960
+ break;
961
+ case 401:
962
+ logger.error('Invalid or missing hula API key. Run "hula login".');
963
+ break;
964
+ case 402: {
965
+ logger.error(data.error ||
966
+ 'Error Watchers require Pro with PR credits — the same as every other launch path.');
967
+ const actionUrl = data.buyCreditsUrl || data.upgradeUrl;
968
+ if (actionUrl) {
969
+ logger.info(` Manage your plan or credits at ${actionUrl}.`);
970
+ }
971
+ break;
972
+ }
973
+ case 403:
974
+ logger.error(`You do not have access to ${ctx.project ?? 'this project'}.`);
975
+ break;
976
+ case 404:
977
+ if (ctx.watcherId) {
978
+ logger.error(`Watcher ${ctx.watcherId} not found for ${ctx.project ?? 'this project'} (or this server may not support Error Watchers yet).`);
979
+ }
980
+ else {
981
+ logger.error('No Error Watcher endpoint found — this server may not support Error Watchers yet.');
982
+ }
983
+ break;
984
+ case 409:
985
+ // The ingest webhook's 409. It means the credential authenticated but
986
+ // watcher resolution found nothing, so the fix is to create or enable a
987
+ // watcher — never to re-issue the key.
988
+ logger.error(data.error ||
989
+ `No enabled Error Watcher is configured for ${ctx.project ?? 'this project'}.`);
990
+ logger.info(' Your ingest key is valid — the project just has no watcher to apply.');
991
+ logger.info(' Create one: hula error-watcher --create --name api-prod');
992
+ logger.info(' Or re-enable an existing one:');
993
+ logger.info(' hula error-watcher --list');
994
+ logger.info(' hula error-watcher --update <id> --enabled true');
995
+ break;
996
+ case 500:
997
+ logger.error(`Server error: ${data.error || 'Internal server error'}`);
998
+ break;
999
+ default:
1000
+ logger.error(`Request failed with status ${status}: ${data.error || 'unexpected error'}`);
1001
+ }
1002
+ return;
1003
+ }
1004
+ if (error.code === 'ECONNREFUSED') {
1005
+ logger.error(`Cannot connect to server at ${serverUrl}`);
1006
+ logger.info('Check --url or config.hulaProjectUrl.');
1007
+ }
1008
+ else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
1009
+ logger.error(`Request to ${serverUrl} timed out.`);
1010
+ logger.info('Check --url or config.hulaProjectUrl.');
1011
+ }
1012
+ else {
1013
+ logger.error(`Network error contacting ${serverUrl}: ${error.message}`);
1014
+ logger.info('Check --url or config.hulaProjectUrl.');
1015
+ }
1016
+ }
1017
+ //# sourceMappingURL=errorWatcher.js.map