@haystackeditor/cli 0.15.10 → 0.15.12
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/dist/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.js +185 -0
- package/dist/commands/pr.d.ts +10 -0
- package/dist/commands/pr.js +103 -0
- package/dist/commands/rules.d.ts +4 -0
- package/dist/commands/rules.js +98 -0
- package/dist/commands/schema-cmd.d.ts +2 -0
- package/dist/commands/schema-cmd.js +41 -0
- package/dist/commands/setup.js +62 -7
- package/dist/commands/submit.js +32 -4
- package/dist/commands/tokens.d.ts +14 -0
- package/dist/commands/tokens.js +184 -0
- package/dist/commands/triage.js +3 -2
- package/dist/commands/webhooks.d.ts +18 -0
- package/dist/commands/webhooks.js +210 -0
- package/dist/index.js +223 -4
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +20 -0
- package/dist/triage/runner.js +15 -5
- package/dist/utils/analysis-api.d.ts +12 -5
- package/dist/utils/analysis-api.js +155 -82
- package/dist/utils/auth.d.ts +29 -0
- package/dist/utils/auth.js +70 -2
- package/dist/utils/git.d.ts +36 -0
- package/dist/utils/git.js +113 -2
- package/dist/utils/prompter.js +4 -1
- package/package.json +6 -3
- package/schemas/pr.v1.json +34 -0
- package/schemas/setup.v1.json +21 -0
- package/schemas/triage.v1.json +50 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived CLI tokens (hsk_live_*).
|
|
3
|
+
*
|
|
4
|
+
* Operator stories:
|
|
5
|
+
* - `haystack login --headless [--label NAME] [--scope repo:owner/name,...]`
|
|
6
|
+
* Opens the browser to mint a token via the auth-worker, then writes it
|
|
7
|
+
* locally so subsequent CLI calls auth via Bearer.
|
|
8
|
+
* - `haystack tokens list`
|
|
9
|
+
* Lists tokens on the server side (the local file only stores ONE
|
|
10
|
+
* active token at a time; the server may have many tied to your user).
|
|
11
|
+
* - `haystack tokens revoke <id>`
|
|
12
|
+
* Revokes a server-side token. If the revoked token is the one
|
|
13
|
+
* currently on disk, the local file is wiped so the next CLI call
|
|
14
|
+
* doesn't keep using a dead token.
|
|
15
|
+
* - `haystack logout --headless`
|
|
16
|
+
* Removes the local headless token without touching the server.
|
|
17
|
+
*
|
|
18
|
+
* The mint flow uses a short-lived browser handshake (the auth-worker has
|
|
19
|
+
* existing cookie-session auth — we can't replicate it in pure CLI). The
|
|
20
|
+
* dashboard renders a "Generate CLI token" page that POSTs to
|
|
21
|
+
* /api/cli-tokens/mint, then redirects back to a one-time URL the CLI
|
|
22
|
+
* polls (deeper headless flow can be added later, see follow-ups).
|
|
23
|
+
*
|
|
24
|
+
* For initial bootstrapping (and CI), `--token <value>` lets the operator
|
|
25
|
+
* paste a server-minted token directly.
|
|
26
|
+
*/
|
|
27
|
+
import chalk from 'chalk';
|
|
28
|
+
import { loadToken, readHeadlessToken, writeHeadlessToken, clearHeadlessToken } from '../utils/auth.js';
|
|
29
|
+
const HEADLESS_BASE = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
|
|
30
|
+
export async function headlessLoginCommand(opts) {
|
|
31
|
+
if (opts.token) {
|
|
32
|
+
if (!opts.token.startsWith('hsk_live_')) {
|
|
33
|
+
console.error(chalk.red('Expected a token starting with hsk_live_.'));
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
const path = await writeHeadlessToken({
|
|
37
|
+
token: opts.token,
|
|
38
|
+
label: opts.label ?? 'paste',
|
|
39
|
+
created_at: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
console.log(chalk.green('✓ Headless token saved'));
|
|
42
|
+
console.log(` ${chalk.dim('path:')} ${path}`);
|
|
43
|
+
console.log(` ${chalk.dim('label:')} ${opts.label ?? 'paste'}`);
|
|
44
|
+
console.log(chalk.dim(' Subsequent `haystack` calls will use this token.'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Polled handshake: the CLI asks the server for a ticket, prints the
|
|
48
|
+
// approval URL, then long-polls for the token. The user clicks "Approve"
|
|
49
|
+
// in the dashboard while logged in.
|
|
50
|
+
//
|
|
51
|
+
// Scope normalization: `--scope all` arrives here as `['all']` (commander
|
|
52
|
+
// splits on commas). The server treats only the literal string `'all'` as
|
|
53
|
+
// the wildcard; an array of `['all']` would deny every repo because the
|
|
54
|
+
// string "all" isn't a real `owner/name`. Detect and unwrap.
|
|
55
|
+
const scopePayload = !opts.scope
|
|
56
|
+
? undefined
|
|
57
|
+
: opts.scope.length === 1 && opts.scope[0].toLowerCase() === 'all'
|
|
58
|
+
? { repos: 'all' }
|
|
59
|
+
: { repos: opts.scope };
|
|
60
|
+
const startRes = await fetch(`${HEADLESS_BASE}/api/cli-tokens/start-handshake`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Haystack-CLI' },
|
|
63
|
+
body: JSON.stringify({ label: opts.label, scope: scopePayload }),
|
|
64
|
+
});
|
|
65
|
+
if (!startRes.ok) {
|
|
66
|
+
console.error(chalk.red(`Could not start handshake: ${startRes.status}`));
|
|
67
|
+
console.error(' ' + (await startRes.text()).slice(0, 400));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const start = (await startRes.json());
|
|
71
|
+
console.log(chalk.bold('\nHaystack headless login\n'));
|
|
72
|
+
console.log(' Open this URL in a browser logged in to Haystack:');
|
|
73
|
+
console.log(` ${chalk.cyan(start.approval_url)}`);
|
|
74
|
+
if (opts.label)
|
|
75
|
+
console.log(` ${chalk.dim('Label:')} ${opts.label}`);
|
|
76
|
+
if (opts.scope)
|
|
77
|
+
console.log(` ${chalk.dim('Scope:')} ${opts.scope.join(', ')}`);
|
|
78
|
+
console.log('');
|
|
79
|
+
process.stdout.write(chalk.dim(' Waiting for approval... (Ctrl-C to cancel)'));
|
|
80
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
81
|
+
const pollUrl = `${HEADLESS_BASE}/api/cli-tokens/handshake/${encodeURIComponent(start.ticket)}`;
|
|
82
|
+
let result = null;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
85
|
+
process.stdout.write(chalk.dim('.'));
|
|
86
|
+
// Transient network errors during long-polling are expected (CF can
|
|
87
|
+
// close idle connections, ISP hiccups). Log to stderr so the user
|
|
88
|
+
// sees that we're retrying and not just silently looping.
|
|
89
|
+
const res = await fetch(pollUrl, { headers: { 'User-Agent': 'Haystack-CLI' } }).catch((err) => {
|
|
90
|
+
process.stderr.write(chalk.dim(`\n poll error: ${err instanceof Error ? err.message : String(err)}; retrying\n`));
|
|
91
|
+
return null;
|
|
92
|
+
});
|
|
93
|
+
if (!res)
|
|
94
|
+
continue;
|
|
95
|
+
if (res.status === 202)
|
|
96
|
+
continue;
|
|
97
|
+
if (res.status === 404) {
|
|
98
|
+
process.stdout.write('\n');
|
|
99
|
+
console.error(chalk.red('Handshake expired before approval. Re-run `haystack login --headless`.'));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
// Transient — keep polling. Log loudly on stderr so it's not silent.
|
|
104
|
+
process.stderr.write(chalk.dim(`\n poll ${res.status}; retrying\n`));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
result = (await res.json());
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write('\n');
|
|
111
|
+
if (!result) {
|
|
112
|
+
console.error(chalk.red('Timed out waiting for approval.'));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const path = await writeHeadlessToken({
|
|
116
|
+
token: result.token,
|
|
117
|
+
id: result.id,
|
|
118
|
+
label: opts.label ?? 'cli',
|
|
119
|
+
created_at: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
console.log(chalk.green('✓ Headless token saved'));
|
|
122
|
+
console.log(` ${chalk.dim('path:')} ${path}`);
|
|
123
|
+
console.log(` ${chalk.dim('id:')} ${result.id}`);
|
|
124
|
+
console.log(chalk.dim(' Subsequent `haystack` calls will use this token.'));
|
|
125
|
+
console.log(chalk.dim(' Revoke server-side with: haystack tokens revoke ' + result.id));
|
|
126
|
+
}
|
|
127
|
+
export async function headlessLogoutCommand() {
|
|
128
|
+
await clearHeadlessToken();
|
|
129
|
+
console.log(chalk.green('✓ Headless token removed from disk.'));
|
|
130
|
+
console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack tokens revoke <id>`.'));
|
|
131
|
+
}
|
|
132
|
+
export async function listTokensCommand(opts) {
|
|
133
|
+
const token = await loadToken();
|
|
134
|
+
if (!token) {
|
|
135
|
+
console.error(chalk.red('Not authenticated.'));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const res = await fetch(`${HEADLESS_BASE}/api/cli-tokens/list`, {
|
|
139
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'Haystack-CLI' },
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
console.error(chalk.red(`list failed: ${res.status} ${await res.text()}`));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const data = (await res.json());
|
|
146
|
+
if (opts.json) {
|
|
147
|
+
console.log(JSON.stringify(data, null, 2));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (data.tokens.length === 0) {
|
|
151
|
+
console.log(chalk.dim('No tokens.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const t of data.tokens) {
|
|
155
|
+
const status = t.revoked_at ? chalk.red('revoked') : chalk.green('active');
|
|
156
|
+
console.log(`${status} ${chalk.bold(t.id)} ${t.label}`);
|
|
157
|
+
console.log(` ${chalk.dim('created:')} ${new Date(t.created_at).toISOString()}` +
|
|
158
|
+
(t.last_used_at ? ` ${chalk.dim('last used:')} ${new Date(t.last_used_at).toISOString()}` : ''));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export async function revokeTokenCommand(id) {
|
|
162
|
+
const token = await loadToken();
|
|
163
|
+
if (!token) {
|
|
164
|
+
console.error(chalk.red('Not authenticated.'));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
const res = await fetch(`${HEADLESS_BASE}/api/cli-tokens/${encodeURIComponent(id)}/revoke`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'Haystack-CLI' },
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
console.error(chalk.red(`revoke failed: ${res.status} ${await res.text()}`));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
// If the revoked token is the one currently on disk, clear it so we
|
|
176
|
+
// don't keep using a dead credential next call. We compare by the id
|
|
177
|
+
// we stored at mint time — no extra network call needed.
|
|
178
|
+
const local = await readHeadlessToken();
|
|
179
|
+
if (local?.id === id) {
|
|
180
|
+
await clearHeadlessToken();
|
|
181
|
+
console.log(chalk.dim(' Local headless token file cleared.'));
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.green(`✓ Token ${id} revoked`));
|
|
184
|
+
}
|
package/dist/commands/triage.js
CHANGED
|
@@ -24,6 +24,7 @@ import { checkAnalysisReady, fetchRatingSynthesis, fetchAutoFixManifest } from '
|
|
|
24
24
|
import { parseRemoteUrl } from '../utils/git.js';
|
|
25
25
|
import { loadToken } from '../utils/auth.js';
|
|
26
26
|
import { loadPendingSubmit, updatePendingSubmit, clearPendingSubmit } from '../utils/pending-state.js';
|
|
27
|
+
import { withSchema } from '../schema.js';
|
|
27
28
|
// ============================================================================
|
|
28
29
|
// PR identifier parsing
|
|
29
30
|
// ============================================================================
|
|
@@ -137,7 +138,7 @@ function printRichOutput(pr, synthesis, manifest) {
|
|
|
137
138
|
console.log(` ${chalk.dim(reviewUrl)}\n`);
|
|
138
139
|
}
|
|
139
140
|
function printJsonOutput(pr, synthesis, manifest) {
|
|
140
|
-
const output = {
|
|
141
|
+
const output = withSchema('triage', {
|
|
141
142
|
owner: pr.owner,
|
|
142
143
|
repo: pr.repo,
|
|
143
144
|
prNumber: pr.prNumber,
|
|
@@ -154,7 +155,7 @@ function printJsonOutput(pr, synthesis, manifest) {
|
|
|
154
155
|
// True when the auto-fixer is handling this finding — don't fix it yourself.
|
|
155
156
|
autoFixing: isAutoFixing(f, manifest),
|
|
156
157
|
})),
|
|
157
|
-
};
|
|
158
|
+
});
|
|
158
159
|
console.log(JSON.stringify(output, null, 2));
|
|
159
160
|
}
|
|
160
161
|
function ratingStars(rating) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function registerWebhook(opts: {
|
|
2
|
+
url: string;
|
|
3
|
+
events?: string[];
|
|
4
|
+
secretEnv?: string;
|
|
5
|
+
repo?: string;
|
|
6
|
+
}): Promise<void>;
|
|
7
|
+
export declare function listWebhooks(opts: {
|
|
8
|
+
repo?: string;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}): Promise<void>;
|
|
11
|
+
export declare function rotateWebhookSecret(id: string): Promise<void>;
|
|
12
|
+
export declare function setWebhookEnabled(id: string, enabled: boolean): Promise<void>;
|
|
13
|
+
export declare function listDeliveries(opts: {
|
|
14
|
+
repo?: string;
|
|
15
|
+
limit?: number;
|
|
16
|
+
json?: boolean;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
export declare function replayDelivery(id: string): Promise<void>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack webhooks ...` — register and inspect outbound webhooks.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities split intentionally:
|
|
5
|
+
*
|
|
6
|
+
* Local edits to .haystack.json (`register`)
|
|
7
|
+
* The .haystack.json file IS the source of truth for which URLs a repo
|
|
8
|
+
* wants to receive events on. The wizard writes it; humans edit it;
|
|
9
|
+
* `haystack webhooks register` is a typed UI over the same edit. After
|
|
10
|
+
* writing the file, this command POSTs to the haystack-webhooks worker
|
|
11
|
+
* to create the registration row and obtain the HMAC secret.
|
|
12
|
+
*
|
|
13
|
+
* Remote queries (`list`, `deliveries`, `rotate-secret`, `replay`, etc.)
|
|
14
|
+
* Hit the worker's /admin endpoints. Auth is CF Access on the worker
|
|
15
|
+
* side; the CLI relies on the operator having a CF Access session
|
|
16
|
+
* (cookie or service-token).
|
|
17
|
+
*
|
|
18
|
+
* Secret handling:
|
|
19
|
+
* The worker returns the freshly-minted HMAC secret ONCE, on register or
|
|
20
|
+
* rotate-secret. We print it to the operator and do not persist it. They
|
|
21
|
+
* are expected to stash it in their CI's secret store and reference it
|
|
22
|
+
* from .haystack.json via "secret_ref": "env:HAYSTACK_WEBHOOK_SECRET".
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { resolve } from 'node:path';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
28
|
+
import { loadToken } from '../utils/auth.js';
|
|
29
|
+
const DEFAULT_WORKER_URL = process.env.HAYSTACK_WEBHOOKS_URL ??
|
|
30
|
+
'https://haystack-webhooks.akshaysg.workers.dev';
|
|
31
|
+
// Keep in lockstep with infra/webhooks-worker/src/types.ts ALLOWED_EVENTS
|
|
32
|
+
// and agent/cloudflare/src/webhook-emitter.ts WebhookEvent. The three
|
|
33
|
+
// lists must agree: a CLI-accepted event with no worker/emitter support
|
|
34
|
+
// either 400s at registration or silently never fires.
|
|
35
|
+
const ALLOWED_EVENTS = [
|
|
36
|
+
'triage.completed',
|
|
37
|
+
'triage.failed',
|
|
38
|
+
'fixer.completed',
|
|
39
|
+
'merge_queue.state_changed',
|
|
40
|
+
// 'review_chat.response_ready' deferred — chat CLI was cut, no producer.
|
|
41
|
+
'pr.classified',
|
|
42
|
+
];
|
|
43
|
+
function configPath() {
|
|
44
|
+
return resolve(process.cwd(), '.haystack.json');
|
|
45
|
+
}
|
|
46
|
+
function readConfig() {
|
|
47
|
+
const p = configPath();
|
|
48
|
+
if (!existsSync(p)) {
|
|
49
|
+
throw new Error(`.haystack.json not found in ${process.cwd()}. Run \`haystack init\` first.`);
|
|
50
|
+
}
|
|
51
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
function writeConfig(cfg) {
|
|
54
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
55
|
+
}
|
|
56
|
+
function currentRepo() {
|
|
57
|
+
const r = parseRemoteUrl('origin');
|
|
58
|
+
return `${r.owner}/${r.repo}`;
|
|
59
|
+
}
|
|
60
|
+
async function adminFetch(path, init = {}) {
|
|
61
|
+
const token = await loadToken();
|
|
62
|
+
const headers = {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
...(init.headers ?? {}),
|
|
65
|
+
};
|
|
66
|
+
if (token)
|
|
67
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
68
|
+
return fetch(`${DEFAULT_WORKER_URL}${path}`, {
|
|
69
|
+
method: init.method,
|
|
70
|
+
headers,
|
|
71
|
+
body: init.body == null
|
|
72
|
+
? undefined
|
|
73
|
+
: typeof init.body === 'string'
|
|
74
|
+
? init.body
|
|
75
|
+
: JSON.stringify(init.body),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function readJsonOrFail(res) {
|
|
79
|
+
const body = await res.text();
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw new Error(`worker ${res.status}: ${body.slice(0, 512)}`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(body);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error(`worker returned non-JSON: ${body.slice(0, 200)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ─── register ────────────────────────────────────────────────────────────────
|
|
91
|
+
export async function registerWebhook(opts) {
|
|
92
|
+
const repo = opts.repo ?? currentRepo();
|
|
93
|
+
const events = opts.events ?? ['triage.completed', 'triage.failed', 'fixer.completed'];
|
|
94
|
+
for (const e of events) {
|
|
95
|
+
if (!ALLOWED_EVENTS.includes(e)) {
|
|
96
|
+
throw new Error(`Unknown event "${e}". Allowed: ${ALLOWED_EVENTS.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const res = await adminFetch('/admin/registrations', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: { repo, url: opts.url, events },
|
|
102
|
+
});
|
|
103
|
+
const data = (await readJsonOrFail(res));
|
|
104
|
+
// Update .haystack.json. Append; do not overwrite an existing entry — the
|
|
105
|
+
// operator may want multiple webhooks pointing at different endpoints.
|
|
106
|
+
const cfg = readConfig();
|
|
107
|
+
cfg.webhooks = cfg.webhooks ?? [];
|
|
108
|
+
cfg.webhooks.push({
|
|
109
|
+
url: opts.url,
|
|
110
|
+
events,
|
|
111
|
+
secret_ref: opts.secretEnv ? `env:${opts.secretEnv}` : undefined,
|
|
112
|
+
});
|
|
113
|
+
writeConfig(cfg);
|
|
114
|
+
console.log(chalk.green('✓ Webhook registered'));
|
|
115
|
+
console.log(` ${chalk.dim('repo:')} ${repo}`);
|
|
116
|
+
console.log(` ${chalk.dim('url:')} ${opts.url}`);
|
|
117
|
+
console.log(` ${chalk.dim('events:')} ${events.join(', ')}`);
|
|
118
|
+
console.log(` ${chalk.dim('id:')} ${data.id}`);
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(chalk.yellow.bold('HMAC secret (save this now — it cannot be retrieved again):'));
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log(` ${chalk.bold(data.secret)}`);
|
|
123
|
+
console.log('');
|
|
124
|
+
if (opts.secretEnv) {
|
|
125
|
+
console.log(chalk.dim(`Stash it in your secret store and expose it as $${opts.secretEnv}.`));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.log(chalk.dim('Tip: pass --secret-env NAME to record where the secret will live.'));
|
|
129
|
+
}
|
|
130
|
+
console.log(chalk.dim('Lost the secret? Run: haystack webhooks rotate-secret ' + data.id));
|
|
131
|
+
}
|
|
132
|
+
// ─── list ────────────────────────────────────────────────────────────────────
|
|
133
|
+
export async function listWebhooks(opts) {
|
|
134
|
+
const repo = opts.repo ?? currentRepo();
|
|
135
|
+
const res = await adminFetch(`/admin/registrations?repo=${encodeURIComponent(repo)}`);
|
|
136
|
+
const data = (await readJsonOrFail(res));
|
|
137
|
+
if (opts.json) {
|
|
138
|
+
console.log(JSON.stringify(data, null, 2));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (data.registrations.length === 0) {
|
|
142
|
+
console.log(chalk.dim(`No webhooks registered for ${repo}.`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const r of data.registrations) {
|
|
146
|
+
const state = r.disabled ? chalk.red(`disabled (${r.disabled_reason ?? 'unknown'})`) : chalk.green('active');
|
|
147
|
+
console.log(`${chalk.bold(r.url)} ${state}`);
|
|
148
|
+
console.log(` ${chalk.dim('id:')} ${r.id}`);
|
|
149
|
+
console.log(` ${chalk.dim('events:')} ${r.events.join(', ')}`);
|
|
150
|
+
if (r.consecutive_5xx > 0) {
|
|
151
|
+
console.log(` ${chalk.dim('5xx:')} ${r.consecutive_5xx} consecutive`);
|
|
152
|
+
}
|
|
153
|
+
console.log('');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// ─── rotate-secret ───────────────────────────────────────────────────────────
|
|
157
|
+
export async function rotateWebhookSecret(id) {
|
|
158
|
+
const res = await adminFetch(`/admin/registrations/${encodeURIComponent(id)}/rotate-secret`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
});
|
|
161
|
+
const data = (await readJsonOrFail(res));
|
|
162
|
+
console.log(chalk.green('✓ Secret rotated'));
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(chalk.yellow.bold('New HMAC secret:'));
|
|
165
|
+
console.log(` ${chalk.bold(data.secret)}`);
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(chalk.red.bold('The previous secret is now invalid. Update your receiver before the next event fires.'));
|
|
168
|
+
}
|
|
169
|
+
// ─── enable / disable ────────────────────────────────────────────────────────
|
|
170
|
+
export async function setWebhookEnabled(id, enabled) {
|
|
171
|
+
const action = enabled ? 'enable' : 'disable';
|
|
172
|
+
const res = await adminFetch(`/admin/registrations/${encodeURIComponent(id)}/${action}`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
});
|
|
175
|
+
await readJsonOrFail(res);
|
|
176
|
+
console.log(chalk.green(`✓ Webhook ${id} ${action}d`));
|
|
177
|
+
}
|
|
178
|
+
// ─── deliveries / replay ─────────────────────────────────────────────────────
|
|
179
|
+
export async function listDeliveries(opts) {
|
|
180
|
+
const repo = opts.repo ?? currentRepo();
|
|
181
|
+
const limit = opts.limit ?? 20;
|
|
182
|
+
const res = await adminFetch(`/admin/deliveries?repo=${encodeURIComponent(repo)}&limit=${limit}`);
|
|
183
|
+
const data = (await readJsonOrFail(res));
|
|
184
|
+
if (opts.json) {
|
|
185
|
+
console.log(JSON.stringify(data, null, 2));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (data.deliveries.length === 0) {
|
|
189
|
+
console.log(chalk.dim(`No deliveries for ${repo}.`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const d of data.deliveries) {
|
|
193
|
+
const color = d.status === 'delivered' ? chalk.green
|
|
194
|
+
: d.status === 'dead' ? chalk.red
|
|
195
|
+
: d.status === 'failed' ? chalk.yellow
|
|
196
|
+
: chalk.dim;
|
|
197
|
+
const code = d.last_status_code != null ? ` ${d.last_status_code}` : '';
|
|
198
|
+
console.log(`${color(d.status.padEnd(10))} ${chalk.bold(d.event.padEnd(28))} ` +
|
|
199
|
+
`attempts=${d.attempts}${code} ${chalk.dim(d.id)}`);
|
|
200
|
+
if (d.last_error)
|
|
201
|
+
console.log(` ${chalk.dim('error:')} ${d.last_error}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function replayDelivery(id) {
|
|
205
|
+
const res = await adminFetch(`/admin/deliveries/${encodeURIComponent(id)}/replay`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
});
|
|
208
|
+
await readJsonOrFail(res);
|
|
209
|
+
console.log(chalk.green(`✓ Replay queued for ${id}`));
|
|
210
|
+
}
|