hub-launch 1.23.0 → 1.25.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.
- package/CHANGELOG.md +35 -0
- package/README.md +59 -0
- package/dist/commands/errorWatcher.d.ts +147 -0
- package/dist/commands/errorWatcher.d.ts.map +1 -0
- package/dist/commands/errorWatcher.fixture.d.ts +32 -0
- package/dist/commands/errorWatcher.fixture.d.ts.map +1 -0
- package/dist/commands/errorWatcher.fixture.js +32 -0
- package/dist/commands/errorWatcher.fixture.js.map +1 -0
- package/dist/commands/errorWatcher.js +1017 -0
- package/dist/commands/errorWatcher.js.map +1 -0
- package/dist/commands/launch.d.ts.map +1 -1
- package/dist/commands/launch.js +94 -19
- package/dist/commands/launch.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/templates/proceed-instructions.md +2 -0
- package/dist/templates/skills/hula-confirm/SKILL.md +1 -1
- package/dist/templates/skills/hula-plan/SKILL.md +1 -0
- package/dist/types/config.schema.d.ts +3 -0
- package/dist/types/config.schema.d.ts.map +1 -1
- package/dist/types/config.schema.js +4 -0
- package/dist/types/config.schema.js.map +1 -1
- package/dist/types/errorWatcher.schema.d.ts +72 -0
- package/dist/types/errorWatcher.schema.d.ts.map +1 -0
- package/dist/types/errorWatcher.schema.js +18 -0
- package/dist/types/errorWatcher.schema.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- 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
|