@cookiecrumbs-eu/mcp 0.7.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/LICENSE +134 -0
- package/README.md +186 -0
- package/dist/cli/src/api.js +192 -0
- package/dist/cli/src/auth.js +106 -0
- package/dist/cli/src/commands/_shared.js +78 -0
- package/dist/cli/src/commands/alerts.js +85 -0
- package/dist/cli/src/commands/auth.js +92 -0
- package/dist/cli/src/commands/declaration.js +45 -0
- package/dist/cli/src/commands/diff.js +26 -0
- package/dist/cli/src/commands/domains.js +44 -0
- package/dist/cli/src/commands/export.js +136 -0
- package/dist/cli/src/commands/init.js +134 -0
- package/dist/cli/src/commands/install.js +77 -0
- package/dist/cli/src/commands/issues.js +61 -0
- package/dist/cli/src/commands/link.js +41 -0
- package/dist/cli/src/commands/logs.js +98 -0
- package/dist/cli/src/commands/open.js +45 -0
- package/dist/cli/src/commands/pull.js +89 -0
- package/dist/cli/src/commands/push.js +110 -0
- package/dist/cli/src/commands/scan.js +94 -0
- package/dist/cli/src/commands/schedule.js +97 -0
- package/dist/cli/src/commands/services.js +143 -0
- package/dist/cli/src/commands/sites.js +111 -0
- package/dist/cli/src/commands/status.js +90 -0
- package/dist/cli/src/commands/templates.js +133 -0
- package/dist/cli/src/commands/tokens.js +50 -0
- package/dist/cli/src/commands/usage.js +41 -0
- package/dist/cli/src/commands/versions.js +95 -0
- package/dist/cli/src/commands/webhooks.js +164 -0
- package/dist/cli/src/configpkg.js +10 -0
- package/dist/cli/src/diff.js +63 -0
- package/dist/cli/src/errors.js +20 -0
- package/dist/cli/src/frameworks.js +141 -0
- package/dist/cli/src/index.js +100 -0
- package/dist/cli/src/jobs.js +59 -0
- package/dist/cli/src/merge.js +38 -0
- package/dist/cli/src/output.js +112 -0
- package/dist/cli/src/project.js +269 -0
- package/dist/cli/src/util.js +122 -0
- package/dist/config/rules_reference.json +569 -0
- package/dist/config/src/canon.js +36 -0
- package/dist/config/src/declaration.js +38 -0
- package/dist/config/src/defaults.js +804 -0
- package/dist/config/src/export.js +130 -0
- package/dist/config/src/index.js +16 -0
- package/dist/config/src/lint.js +139 -0
- package/dist/config/src/regimes.js +62 -0
- package/dist/config/src/rules.js +90 -0
- package/dist/config/src/schema.js +323 -0
- package/dist/config/src/theme.js +147 -0
- package/dist/config/src/verify.js +51 -0
- package/dist/config/src/webhooks.js +309 -0
- package/dist/mcp/src/auth.js +40 -0
- package/dist/mcp/src/client.js +44 -0
- package/dist/mcp/src/diff.js +134 -0
- package/dist/mcp/src/index.js +25 -0
- package/dist/mcp/src/matrix.js +106 -0
- package/dist/mcp/src/server.js +171 -0
- package/dist/mcp/src/shared.js +147 -0
- package/dist/mcp/src/tools-config.js +943 -0
- package/dist/mcp/src/tools.js +650 -0
- package/package.json +66 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a site's configuration consists of beyond the banner draft, as MCP tools: the site
|
|
3
|
+
* itself (name, retention, settings), the scan schedule, services, issue suppression, install
|
|
4
|
+
* checks, domain verification, alerts and alert channels, webhooks, templates, version promotion
|
|
5
|
+
* and scheduled exports. Together with tools.ts this is the whole dashboard, reachable from an
|
|
6
|
+
* agent — the point of the product.
|
|
7
|
+
*
|
|
8
|
+
* The same two rules as tools.ts: a tool is advertised only when the token carries one of its
|
|
9
|
+
* scopes, and every write is two-step (`confirm:false` shows what would change and changes
|
|
10
|
+
* nothing; `confirm:true` does it). Destructive tools carry `destructiveHint`.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { CliError } from "../../cli/src/errors.js";
|
|
14
|
+
import { jsonDiff } from "./diff.js";
|
|
15
|
+
import { meta } from "./matrix.js";
|
|
16
|
+
import { ENV, allSites, jsonBlock, resolveSite } from "./shared.js";
|
|
17
|
+
// --- small helpers ---------------------------------------------------------
|
|
18
|
+
const CONFIRM = z.boolean().optional().describe('false (default) = dry run: show what would change and change nothing; true = do it');
|
|
19
|
+
const SITE = z.string().optional().describe('site id, primary domain or name (optional when the token is bound to one site or the workspace has only one)');
|
|
20
|
+
/** The dry-run block for writes that carry no banner lint. */
|
|
21
|
+
function plan(what, diff, extra = []) {
|
|
22
|
+
return [
|
|
23
|
+
`DRY RUN — nothing was changed. ${what}`,
|
|
24
|
+
'',
|
|
25
|
+
diff ? '```diff\n' + diff + '\n```' : 'Nothing would change — the target already has this content.',
|
|
26
|
+
...extra,
|
|
27
|
+
'',
|
|
28
|
+
'Call the same tool again with `confirm: true` to apply it.',
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
31
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
32
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
33
|
+
const bool = (v) => (typeof v === 'boolean' ? v : undefined);
|
|
34
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((s) => s.trim()).filter(Boolean) : undefined);
|
|
35
|
+
/** Keep only the keys that were actually given — a patch never carries `undefined`. */
|
|
36
|
+
function compact(o) {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [k, v] of Object.entries(o))
|
|
39
|
+
if (v !== undefined)
|
|
40
|
+
out[k] = v;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Everything the site row says apart from timestamps and keys — what an agent compares. */
|
|
44
|
+
const siteView = (s) => ({ id: s.id, name: s.name, primary_domain: s.primary_domain, status: s.status, retention_months: s.retention_months, settings: s.settings ?? {} });
|
|
45
|
+
async function serviceByRef(ctx, site, ref) {
|
|
46
|
+
const services = await ctx.api.services(site);
|
|
47
|
+
return (services.find((s) => s.id === ref) ??
|
|
48
|
+
services.find((s) => String(s.name).toLowerCase() === ref.toLowerCase()) ??
|
|
49
|
+
services.find((s) => String(s.provider_name ?? '').toLowerCase() === ref.toLowerCase()) ??
|
|
50
|
+
services.find((s) => String(s.provider_domain ?? '').toLowerCase() === ref.toLowerCase()) ??
|
|
51
|
+
null);
|
|
52
|
+
}
|
|
53
|
+
async function templateByRef(ctx, ref) {
|
|
54
|
+
const templates = await ctx.api.templates();
|
|
55
|
+
return templates.find((t) => t.id === ref) ?? templates.find((t) => t.name.toLowerCase() === ref.toLowerCase()) ?? null;
|
|
56
|
+
}
|
|
57
|
+
async function webhookByRef(ctx, ref) {
|
|
58
|
+
const hooks = await ctx.api.webhooks();
|
|
59
|
+
return hooks.find((h) => h.id === ref) ?? hooks.find((h) => h.url === ref) ?? null;
|
|
60
|
+
}
|
|
61
|
+
async function channels(ctx) {
|
|
62
|
+
const r = await ctx.api.request('GET', '/alert-channels');
|
|
63
|
+
return r.items ?? [];
|
|
64
|
+
}
|
|
65
|
+
async function channelByRef(ctx, ref) {
|
|
66
|
+
const all = await channels(ctx);
|
|
67
|
+
return all.find((c) => c.id === ref) ?? all.find((c) => c.name.toLowerCase() === ref.toLowerCase()) ?? null;
|
|
68
|
+
}
|
|
69
|
+
async function exportSchedules(ctx, siteId) {
|
|
70
|
+
const r = await ctx.api.request('GET', '/export-schedules', { query: { site_id: siteId } });
|
|
71
|
+
return r.items ?? [];
|
|
72
|
+
}
|
|
73
|
+
const SERVICE_FIELDS = {
|
|
74
|
+
category_key: z.string().optional().describe("one of the site's category keys, e.g. analytics"),
|
|
75
|
+
provider_name: z.string().optional(),
|
|
76
|
+
provider_domain: z.string().optional(),
|
|
77
|
+
privacy_url: z.string().nullable().optional(),
|
|
78
|
+
host_patterns: z.array(z.string()).optional().describe('hosts the service loads from; drives blocking'),
|
|
79
|
+
script_patterns: z.array(z.string()).optional(),
|
|
80
|
+
iframe_patterns: z.array(z.string()).optional(),
|
|
81
|
+
cookie_patterns: z.array(z.string()).optional(),
|
|
82
|
+
storage_keys: z.array(z.string()).optional(),
|
|
83
|
+
legal_basis: z.enum(['consent', 'legitimate_interest', 'contract', 'legal_obligation']).optional(),
|
|
84
|
+
justification: z.string().nullable().optional(),
|
|
85
|
+
first_party: z.boolean().optional(),
|
|
86
|
+
status: z.enum(['discovered', 'managed', 'ignored']).optional(),
|
|
87
|
+
ignore_reason: z.string().nullable().optional(),
|
|
88
|
+
description: z.string().optional().describe('what the visitor is told (English)'),
|
|
89
|
+
};
|
|
90
|
+
function servicePatch(args) {
|
|
91
|
+
const p = compact({
|
|
92
|
+
category_key: str(args.category_key),
|
|
93
|
+
provider_name: str(args.provider_name),
|
|
94
|
+
provider_domain: str(args.provider_domain)?.toLowerCase(),
|
|
95
|
+
host_patterns: list(args.host_patterns),
|
|
96
|
+
script_patterns: list(args.script_patterns),
|
|
97
|
+
iframe_patterns: list(args.iframe_patterns),
|
|
98
|
+
cookie_patterns: list(args.cookie_patterns),
|
|
99
|
+
storage_keys: list(args.storage_keys),
|
|
100
|
+
legal_basis: str(args.legal_basis),
|
|
101
|
+
first_party: bool(args.first_party),
|
|
102
|
+
status: str(args.status),
|
|
103
|
+
});
|
|
104
|
+
if ('privacy_url' in args)
|
|
105
|
+
p.privacy_url = args.privacy_url === null ? null : str(args.privacy_url) ?? null;
|
|
106
|
+
if ('justification' in args)
|
|
107
|
+
p.justification = args.justification === null ? null : str(args.justification) ?? null;
|
|
108
|
+
if ('ignore_reason' in args)
|
|
109
|
+
p.ignore_reason = args.ignore_reason === null ? null : str(args.ignore_reason) ?? null;
|
|
110
|
+
if (str(args.description))
|
|
111
|
+
p.descriptions = { en: str(args.description) };
|
|
112
|
+
return p;
|
|
113
|
+
}
|
|
114
|
+
const SCHEDULE_FIELDS = {
|
|
115
|
+
cadence: z.enum(['monthly', 'weekly', 'daily']).optional().describe('the plan may cap it; the answer shows the cadence in force'),
|
|
116
|
+
page_cap: z.number().int().min(1).max(20000).optional(),
|
|
117
|
+
states: z.array(z.enum(['no_interaction', 'reject_all', 'accept_all'])).optional(),
|
|
118
|
+
start_urls: z.array(z.string()).optional().describe('[] = the home page'),
|
|
119
|
+
include_patterns: z.array(z.string()).optional().describe('path patterns to crawl, e.g. /blog/**; [] = everything'),
|
|
120
|
+
exclude_patterns: z.array(z.string()).optional(),
|
|
121
|
+
respect_robots: z.boolean().optional().describe('false only on a verified domain'),
|
|
122
|
+
paused: z.boolean().optional(),
|
|
123
|
+
};
|
|
124
|
+
// --- the tools -------------------------------------------------------------
|
|
125
|
+
export const CONFIG_TOOLS = [
|
|
126
|
+
// ---- sites ----------------------------------------------------------------------------------
|
|
127
|
+
{
|
|
128
|
+
name: 'create_site',
|
|
129
|
+
...meta('create_site'),
|
|
130
|
+
description: 'Create a site in the workspace: it gets production and preview environments, default categories and a first banner draft. `confirm:false` (default) shows what would be created.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
name: z.string().min(1).max(120),
|
|
133
|
+
primary_domain: z.string().describe('a hostname like www.example.com'),
|
|
134
|
+
languages: z.array(z.string()).optional().describe('BCP-47 tags, default ["en"]'),
|
|
135
|
+
confirm: CONFIRM,
|
|
136
|
+
},
|
|
137
|
+
readOnlyHint: false,
|
|
138
|
+
async run(ctx, args) {
|
|
139
|
+
const name = str(args.name);
|
|
140
|
+
const domain = str(args.primary_domain)?.toLowerCase();
|
|
141
|
+
if (!name || !domain)
|
|
142
|
+
throw new CliError('usage', 'Pass `name` and `primary_domain`.', 2);
|
|
143
|
+
const languages = list(args.languages);
|
|
144
|
+
const proposed = { name, primary_domain: domain, languages: languages?.length ? languages : ['en'] };
|
|
145
|
+
const existing = (await allSites(ctx)).find((s) => s.primary_domain?.toLowerCase() === domain);
|
|
146
|
+
if (args.confirm !== true) {
|
|
147
|
+
return plan(`Would create site “${name}” for ${domain}.`, jsonDiff({}, proposed, 'no site', 'new site'), [
|
|
148
|
+
'',
|
|
149
|
+
existing ? `Note: ${existing.primary_domain} already exists as site ${existing.id} (“${existing.name}”); the server will refuse a duplicate domain.` : 'The domain is not used by another site in this workspace.',
|
|
150
|
+
'The plan’s site limit applies (402 when reached).',
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
const site = await ctx.api.createSite(proposed);
|
|
154
|
+
ctx.sites = null;
|
|
155
|
+
return [`Created site ${site.id} (“${site.name}”, ${site.primary_domain}).`, jsonBlock(siteView(site)), '', 'Next: get_banner to see its draft, scan_site to see what loads, check_install once cc.js is on the page.'].join('\n');
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'update_site',
|
|
160
|
+
...meta('update_site'),
|
|
161
|
+
description: 'Rename a site, set how long consent records are kept (retention_months) or change a site setting. The primary domain cannot change; languages are part of the banner config (update_banner). Two-step: `confirm:false` shows the diff.',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
site: SITE,
|
|
164
|
+
name: z.string().min(1).max(120).optional(),
|
|
165
|
+
retention_months: z.number().int().optional().describe('12, 24, 36 … 120; the plan may cap it'),
|
|
166
|
+
settings: z
|
|
167
|
+
.object({
|
|
168
|
+
public_versions_feed: z.boolean().optional(),
|
|
169
|
+
consent_cookie_name: z.string().nullable().optional().describe('null resets to the default'),
|
|
170
|
+
reask_months: z.number().int().min(1).max(13).nullable().optional(),
|
|
171
|
+
})
|
|
172
|
+
.optional(),
|
|
173
|
+
confirm: CONFIRM,
|
|
174
|
+
},
|
|
175
|
+
readOnlyHint: false,
|
|
176
|
+
async run(ctx, args) {
|
|
177
|
+
const id = await resolveSite(ctx, args.site);
|
|
178
|
+
const current = await ctx.api.site(id);
|
|
179
|
+
const patch = compact({ name: str(args.name), retention_months: num(args.retention_months), settings: args.settings && typeof args.settings === 'object' ? args.settings : undefined });
|
|
180
|
+
if (!Object.keys(patch).length)
|
|
181
|
+
throw new CliError('usage', 'Nothing to change: pass name, retention_months or settings.', 2);
|
|
182
|
+
const after = { ...siteView(current), ...compact({ name: patch.name, retention_months: patch.retention_months }) };
|
|
183
|
+
if (patch.settings) {
|
|
184
|
+
const s = { ...current.settings, ...patch.settings };
|
|
185
|
+
for (const [k, v] of Object.entries(s))
|
|
186
|
+
if (v === null)
|
|
187
|
+
delete s[k];
|
|
188
|
+
after.settings = s;
|
|
189
|
+
}
|
|
190
|
+
const diff = jsonDiff(siteView(current), after, `site ${id} (current)`, `site ${id} (proposed)`);
|
|
191
|
+
if (args.confirm !== true)
|
|
192
|
+
return plan(`Would update site ${id} (“${current.name}”).`, diff, patch.retention_months ? ['', 'Shortening retention deletes older consent records at the next retention run; the RPC refuses a value the plan does not allow (402).'] : []);
|
|
193
|
+
const site = await ctx.api.updateSite(id, patch);
|
|
194
|
+
ctx.sites = null;
|
|
195
|
+
return [`Site ${id} updated.`, jsonBlock(siteView(site))].join('\n');
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'list_domains',
|
|
200
|
+
...meta('list_domains'),
|
|
201
|
+
description: 'The domains of a site with their verification state and the token to publish (DNS TXT or meta tag).',
|
|
202
|
+
inputSchema: { site: SITE },
|
|
203
|
+
readOnlyHint: true,
|
|
204
|
+
async run(ctx, args) {
|
|
205
|
+
const id = await resolveSite(ctx, args.site);
|
|
206
|
+
const items = await ctx.api.domains(id);
|
|
207
|
+
if (!items.length)
|
|
208
|
+
return `Site ${id} has no domains.`;
|
|
209
|
+
return items
|
|
210
|
+
.map((d) => `- ${d.hostname} id ${d.id}\n ${d.verified_at ? `verified ${d.verified_at}` : `NOT verified · method ${d.verification_method} · publish: ${instructions(d)}`}`)
|
|
211
|
+
.join('\n');
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'verify_domain',
|
|
216
|
+
...meta('verify_domain'),
|
|
217
|
+
description: '(Re)issue the ownership-verification token for a domain and say exactly what to publish. The worker performs the check; a passed install check verifies the domain too. Two-step.',
|
|
218
|
+
inputSchema: { site: SITE, domain: z.string().describe('domain id or hostname'), method: z.enum(['dns_txt', 'meta']).optional().describe('default dns_txt'), confirm: CONFIRM },
|
|
219
|
+
readOnlyHint: false,
|
|
220
|
+
async run(ctx, args) {
|
|
221
|
+
const id = await resolveSite(ctx, args.site);
|
|
222
|
+
const ref = String(args.domain ?? '').trim().toLowerCase();
|
|
223
|
+
const d = (await ctx.api.domains(id)).find((x) => x.id === ref || x.hostname.toLowerCase() === ref);
|
|
224
|
+
if (!d)
|
|
225
|
+
return `No domain “${ref}” on site ${id}. Use list_domains.`;
|
|
226
|
+
if (d.verified_at)
|
|
227
|
+
return `${d.hostname} is already verified (${d.verified_at}). Nothing to do.`;
|
|
228
|
+
const method = (str(args.method) ?? 'dns_txt');
|
|
229
|
+
if (args.confirm !== true)
|
|
230
|
+
return plan(`Would set ${d.hostname} to ${method} verification and keep its token.`, jsonDiff({ verification_method: d.verification_method }, { verification_method: method }, 'current', 'proposed'), ['', `You would then publish: ${instructions({ ...d, verification_method: method })}`]);
|
|
231
|
+
const out = await ctx.api.verifyDomain(d.id, method);
|
|
232
|
+
return [`${out.hostname}: ${out.verification_method} verification requested.`, `Publish this, then wait for the next check:`, ` ${instructions(out)}`].join('\n');
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
// ---- scanning: schedule, install, alerts ------------------------------------------------------
|
|
236
|
+
{
|
|
237
|
+
name: 'list_scans',
|
|
238
|
+
...meta('list_scans'),
|
|
239
|
+
description: 'Recent scans of a site (newest first): status, trigger, pages, summary.',
|
|
240
|
+
inputSchema: { site: SITE, limit: z.number().int().min(1).max(100).optional() },
|
|
241
|
+
readOnlyHint: true,
|
|
242
|
+
async run(ctx, args) {
|
|
243
|
+
const id = await resolveSite(ctx, args.site);
|
|
244
|
+
const r = await ctx.api.request('GET', `/sites/${id}/scans`, { query: { limit: num(args.limit) ?? 10 } });
|
|
245
|
+
const items = r.items ?? [];
|
|
246
|
+
if (!items.length)
|
|
247
|
+
return `Site ${id} has no scans yet — scan_site queues one.`;
|
|
248
|
+
return items.map((s) => `- ${s.id} ${s.status} · ${s.environment} · via ${s.trigger} · ${s.pages_crawled ?? 0} pages · queued ${s.queued_at}${s.finished_at ? ` · finished ${s.finished_at}` : ''}${s.summary ? `\n summary ${JSON.stringify(s.summary)}` : ''}`).join('\n');
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'get_scan_diff',
|
|
253
|
+
...meta('get_scan_diff'),
|
|
254
|
+
description: 'What changed between a scan and the one before it: findings added, removed or re-classified.',
|
|
255
|
+
inputSchema: { scan_id: z.string() },
|
|
256
|
+
readOnlyHint: true,
|
|
257
|
+
async run(ctx, args) {
|
|
258
|
+
const out = await ctx.api.scanDiff(String(args.scan_id));
|
|
259
|
+
return jsonBlock(out);
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'get_scan_schedule',
|
|
264
|
+
...meta('get_scan_schedule'),
|
|
265
|
+
description: 'The scheduled-scan settings of a site: cadence, next run, page cap, consent states, start URLs, path patterns, robots, paused.',
|
|
266
|
+
inputSchema: { site: SITE },
|
|
267
|
+
readOnlyHint: true,
|
|
268
|
+
async run(ctx, args) {
|
|
269
|
+
const id = await resolveSite(ctx, args.site);
|
|
270
|
+
return jsonBlock(await ctx.api.scanSchedule(id));
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'set_scan_schedule',
|
|
275
|
+
...meta('set_scan_schedule'),
|
|
276
|
+
description: 'Change the scan schedule. Only the fields you pass change; cadence and page_cap are clamped to the plan and the answer shows the values in force. Two-step.',
|
|
277
|
+
inputSchema: { site: SITE, ...SCHEDULE_FIELDS, confirm: CONFIRM },
|
|
278
|
+
readOnlyHint: false,
|
|
279
|
+
async run(ctx, args) {
|
|
280
|
+
const id = await resolveSite(ctx, args.site);
|
|
281
|
+
const current = await ctx.api.scanSchedule(id);
|
|
282
|
+
const patch = compact({
|
|
283
|
+
cadence: str(args.cadence),
|
|
284
|
+
page_cap: num(args.page_cap),
|
|
285
|
+
states: list(args.states),
|
|
286
|
+
start_urls: list(args.start_urls),
|
|
287
|
+
include_patterns: list(args.include_patterns),
|
|
288
|
+
exclude_patterns: list(args.exclude_patterns),
|
|
289
|
+
respect_robots: bool(args.respect_robots),
|
|
290
|
+
paused: bool(args.paused),
|
|
291
|
+
});
|
|
292
|
+
if (!Object.keys(patch).length)
|
|
293
|
+
throw new CliError('usage', `Nothing to change: pass one of ${Object.keys(SCHEDULE_FIELDS).join(', ')}.`, 2);
|
|
294
|
+
const view = (s) => ({ cadence: s.cadence, page_cap: s.page_cap, states: s.states, start_urls: s.start_urls, include_patterns: s.include_patterns, exclude_patterns: s.exclude_patterns, respect_robots: s.respect_robots, paused: s.paused });
|
|
295
|
+
const diff = jsonDiff(view(current), { ...view(current), ...patch }, `schedule ${id} (current)`, `schedule ${id} (proposed)`);
|
|
296
|
+
if (args.confirm !== true)
|
|
297
|
+
return plan(`Would change the scan schedule of site ${id}.`, diff, ['', `Next scheduled run today: ${current.paused ? 'none (paused)' : current.next_run_at ?? 'not scheduled yet'}. Changing the cadence re-plans it.`]);
|
|
298
|
+
const saved = await ctx.api.updateScanSchedule(id, patch);
|
|
299
|
+
const clamped = [];
|
|
300
|
+
if (patch.cadence && saved.cadence !== patch.cadence)
|
|
301
|
+
clamped.push(`cadence ${patch.cadence} was clamped to ${saved.cadence} by the plan`);
|
|
302
|
+
if (patch.page_cap && saved.page_cap !== patch.page_cap)
|
|
303
|
+
clamped.push(`page_cap ${patch.page_cap} was clamped to ${saved.page_cap} by the plan`);
|
|
304
|
+
return [`Schedule of site ${id} saved.`, ...clamped.map((c) => `Note: ${c}.`), jsonBlock(view(saved)), `Next run: ${saved.paused ? 'none while paused' : saved.next_run_at ?? '—'}`].join('\n');
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'get_install_status',
|
|
309
|
+
...meta('get_install_status'),
|
|
310
|
+
description: 'Is cc.js installed and working on the site? The most recent install checks, newest first.',
|
|
311
|
+
inputSchema: { site: SITE, limit: z.number().int().min(1).max(100).optional() },
|
|
312
|
+
readOnlyHint: true,
|
|
313
|
+
async run(ctx, args) {
|
|
314
|
+
const id = await resolveSite(ctx, args.site);
|
|
315
|
+
const items = await ctx.api.installChecks(id, num(args.limit) ?? 5);
|
|
316
|
+
if (!items.length)
|
|
317
|
+
return `No install check has run for site ${id} yet. check_install queues one.`;
|
|
318
|
+
return [`Latest: ${items[0].ok ? 'OK' : 'NOT OK'} at ${items[0].checked_at} on ${items[0].url}`, ...items.map((c) => `- ${c.checked_at} ${c.ok ? 'ok ' : 'FAIL'} ${c.kind} ${c.url} ${JSON.stringify(c.result)}`)].join('\n');
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'check_install',
|
|
323
|
+
...meta('check_install'),
|
|
324
|
+
description: 'Queue an install check: the worker fetches the page and looks for cc.js with this site’s public key. Optionally wait (`wait` seconds) for the result. A passed check also verifies the domain.',
|
|
325
|
+
inputSchema: { site: SITE, url: z.string().optional().describe('page to check; default the home page'), wait: z.number().int().min(0).max(300).optional().describe('seconds to wait for the result; 0 = return at once') },
|
|
326
|
+
readOnlyHint: false,
|
|
327
|
+
async run(ctx, args) {
|
|
328
|
+
const id = await resolveSite(ctx, args.site);
|
|
329
|
+
const queued = await ctx.api.requestInstallCheck(id, str(args.url));
|
|
330
|
+
const wait = num(args.wait) ?? 0;
|
|
331
|
+
if (!wait)
|
|
332
|
+
return `Install check queued for ${queued.url} (job ${queued.job_id}). Read the result with get_install_status in a moment.`;
|
|
333
|
+
const since = Date.parse(queued.requested_at) || Date.now();
|
|
334
|
+
const deadline = Date.now() + wait * 1000;
|
|
335
|
+
let found = null;
|
|
336
|
+
while (Date.now() < deadline && !found) {
|
|
337
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
338
|
+
found = (await ctx.api.installChecks(id, 5)).find((c) => c.kind === 'install' && Date.parse(c.checked_at) >= since) ?? null;
|
|
339
|
+
}
|
|
340
|
+
if (!found)
|
|
341
|
+
return `Install check queued (job ${queued.job_id}) but no result within ${wait}s — is a worker running? Read it later with get_install_status.`;
|
|
342
|
+
return [`Install ${found.ok ? 'OK' : 'BROKEN'} on ${found.url} (checked ${found.checked_at}).`, jsonBlock(found.result)].join('\n');
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'list_alerts',
|
|
347
|
+
...meta('list_alerts'),
|
|
348
|
+
description: 'The alert inbox: new trackers, pre-consent loads, unclassified trackers, removals, broken installs. Open by default.',
|
|
349
|
+
inputSchema: {
|
|
350
|
+
site: SITE,
|
|
351
|
+
status: z.enum(['open', 'acknowledged', 'resolved', 'all']).optional(),
|
|
352
|
+
kind: z.enum(['new_tracker', 'preconsent', 'unclassified', 'removed', 'install_broken']).optional(),
|
|
353
|
+
min_severity: z.enum(['info', 'low', 'medium', 'high', 'critical']).optional(),
|
|
354
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
355
|
+
},
|
|
356
|
+
readOnlyHint: true,
|
|
357
|
+
async run(ctx, args) {
|
|
358
|
+
const siteId = args.site ? await resolveSite(ctx, args.site) : ctx.siteId ?? undefined;
|
|
359
|
+
const page = await ctx.api.alerts({ site_id: siteId, status: str(args.status) ?? 'open', kind: str(args.kind), min_severity: str(args.min_severity), limit: num(args.limit) ?? 50 });
|
|
360
|
+
if (!page.items.length)
|
|
361
|
+
return `No ${str(args.status) ?? 'open'} alerts.${page.counts ? ` Counts: ${JSON.stringify(page.counts)}` : ''}`;
|
|
362
|
+
return [...page.items.map((a) => `- ${a.id} [${a.severity}] ${a.kind} · ${a.status} · site ${a.site_id ?? 'workspace'} · ${a.created_at ?? ''}\n ${a.title ?? ''}`), page.counts ? `Counts: ${JSON.stringify(page.counts)}` : ''].filter(Boolean).join('\n');
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'acknowledge_alert',
|
|
367
|
+
...meta('acknowledge_alert'),
|
|
368
|
+
description: 'Mark an alert as seen (it stays in the inbox as acknowledged). Two-step.',
|
|
369
|
+
inputSchema: { alert_id: z.string(), confirm: CONFIRM },
|
|
370
|
+
readOnlyHint: false,
|
|
371
|
+
async run(ctx, args) {
|
|
372
|
+
const id = String(args.alert_id);
|
|
373
|
+
if (args.confirm !== true)
|
|
374
|
+
return plan(`Would acknowledge alert ${id}.`, jsonDiff({ status: 'open' }, { status: 'acknowledged' }, 'current', 'proposed'));
|
|
375
|
+
const a = await ctx.api.acknowledgeAlert(id);
|
|
376
|
+
return `Alert ${a.id} is now ${a.status}.`;
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: 'resolve_alert',
|
|
381
|
+
...meta('resolve_alert'),
|
|
382
|
+
description: 'Resolve an alert. Two-step.',
|
|
383
|
+
inputSchema: { alert_id: z.string(), confirm: CONFIRM },
|
|
384
|
+
readOnlyHint: false,
|
|
385
|
+
async run(ctx, args) {
|
|
386
|
+
const id = String(args.alert_id);
|
|
387
|
+
if (args.confirm !== true)
|
|
388
|
+
return plan(`Would resolve alert ${id}.`, jsonDiff({ status: 'open' }, { status: 'resolved' }, 'current', 'proposed'));
|
|
389
|
+
const a = await ctx.api.resolveAlert(id);
|
|
390
|
+
return `Alert ${a.id} is now ${a.status}.`;
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
// ---- services and issues --------------------------------------------------------------------
|
|
394
|
+
{
|
|
395
|
+
name: 'list_services',
|
|
396
|
+
...meta('list_services'),
|
|
397
|
+
description: 'The services (trackers) a site declares: category, provider, legal basis, matching patterns, status.',
|
|
398
|
+
inputSchema: { site: SITE, status: z.enum(['discovered', 'managed', 'ignored']).optional() },
|
|
399
|
+
readOnlyHint: true,
|
|
400
|
+
async run(ctx, args) {
|
|
401
|
+
const id = await resolveSite(ctx, args.site);
|
|
402
|
+
const items = await ctx.api.services(id, str(args.status));
|
|
403
|
+
if (!items.length)
|
|
404
|
+
return `Site ${id} has no services${args.status ? ` with status ${args.status}` : ''}.`;
|
|
405
|
+
return items.map((s) => `- ${s.name} id ${s.id}\n category ${s.category_key} · ${s.status} · basis ${s.legal_basis ?? '—'} · provider ${s.provider_name ?? '—'} (${s.provider_domain ?? '—'})\n hosts ${(s.host_patterns ?? []).join(', ') || '—'} · cookies ${(s.cookie_patterns ?? []).join(', ') || '—'}`).join('\n');
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'add_service',
|
|
410
|
+
...meta('add_service'),
|
|
411
|
+
description: 'Declare a service the site uses (a tracker the scanner did not find, or one to record by hand). It joins the declaration and the block list at the next publish. Two-step.',
|
|
412
|
+
inputSchema: { site: SITE, name: z.string().min(1), ...SERVICE_FIELDS, category_key: z.string().describe("one of the site's category keys, e.g. analytics"), confirm: CONFIRM },
|
|
413
|
+
readOnlyHint: false,
|
|
414
|
+
async run(ctx, args) {
|
|
415
|
+
const id = await resolveSite(ctx, args.site);
|
|
416
|
+
const name = str(args.name);
|
|
417
|
+
const category = str(args.category_key);
|
|
418
|
+
if (!name || !category)
|
|
419
|
+
throw new CliError('usage', 'Pass `name` and `category_key`.', 2);
|
|
420
|
+
const body = { ...servicePatch(args), name, category_key: category, status: str(args.status) ?? 'managed' };
|
|
421
|
+
const dup = await serviceByRef(ctx, id, name);
|
|
422
|
+
if (args.confirm !== true)
|
|
423
|
+
return plan(`Would add service “${name}” (${category}) to site ${id}.`, jsonDiff({}, body, 'no service', 'new service'), dup ? ['', `Note: a service named “${dup.name}” already exists (${dup.id}); use update_service to change it.`] : []);
|
|
424
|
+
const created = await ctx.api.createService(id, body);
|
|
425
|
+
return [`Service ${created.id} added to site ${id}.`, jsonBlock(created), '', 'Publish (push_config) so the declaration and the block list follow.'].join('\n');
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'update_service',
|
|
430
|
+
...meta('update_service'),
|
|
431
|
+
description: 'Change a service: category, provider, matching patterns, legal basis, status. Only the fields you pass change. Two-step.',
|
|
432
|
+
inputSchema: { site: SITE, service: z.string().describe('service id, name, provider name or provider domain'), name: z.string().optional(), ...SERVICE_FIELDS, confirm: CONFIRM },
|
|
433
|
+
readOnlyHint: false,
|
|
434
|
+
async run(ctx, args) {
|
|
435
|
+
const id = await resolveSite(ctx, args.site);
|
|
436
|
+
const svc = await serviceByRef(ctx, id, String(args.service));
|
|
437
|
+
if (!svc)
|
|
438
|
+
return `No service matches “${args.service}” on site ${id}. Use list_services.`;
|
|
439
|
+
const patch = { ...servicePatch(args), ...compact({ name: str(args.name) }) };
|
|
440
|
+
if (!Object.keys(patch).length)
|
|
441
|
+
throw new CliError('usage', 'Nothing to change: pass at least one field.', 2);
|
|
442
|
+
const diff = jsonDiff(svc, { ...svc, ...patch }, `service ${svc.id} (current)`, `service ${svc.id} (proposed)`);
|
|
443
|
+
if (args.confirm !== true)
|
|
444
|
+
return plan(`Would update service “${svc.name}” on site ${id}.`, diff);
|
|
445
|
+
const updated = await ctx.api.updateService(svc.id, patch);
|
|
446
|
+
return [`Service ${updated.id} updated.`, jsonBlock(updated)].join('\n');
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: 'delete_service',
|
|
451
|
+
...meta('delete_service'),
|
|
452
|
+
description: 'Delete a service. Its cookies leave the declaration and its findings return to unclassified on the next scan. Two-step; destructive.',
|
|
453
|
+
inputSchema: { site: SITE, service: z.string().describe('service id or name'), confirm: CONFIRM },
|
|
454
|
+
readOnlyHint: false,
|
|
455
|
+
destructiveHint: true,
|
|
456
|
+
async run(ctx, args) {
|
|
457
|
+
const id = await resolveSite(ctx, args.site);
|
|
458
|
+
const svc = await serviceByRef(ctx, id, String(args.service));
|
|
459
|
+
if (!svc)
|
|
460
|
+
return `No service matches “${args.service}” on site ${id}. Use list_services.`;
|
|
461
|
+
if (args.confirm !== true)
|
|
462
|
+
return plan(`Would DELETE service “${svc.name}” (${svc.id}) from site ${id}.`, jsonDiff(svc, {}, `service ${svc.id}`, 'deleted'), ['', 'This cannot be undone; the service can be declared again with add_service.']);
|
|
463
|
+
await ctx.api.deleteService(svc.id);
|
|
464
|
+
return `Service ${svc.id} (“${svc.name}”) deleted. Publish so the declaration and the block list follow.`;
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
name: 'suppress_issue',
|
|
469
|
+
...meta('suppress_issue'),
|
|
470
|
+
description: 'Suppress a compliance issue with a reason (the reason is the record and appears in the audit trail). Two-step.',
|
|
471
|
+
inputSchema: { site: SITE, issue: z.string().describe('issue id, or an issue code such as unclassified_tracker when the site has exactly one open issue with it'), reason: z.string().min(1).max(500), confirm: CONFIRM },
|
|
472
|
+
readOnlyHint: false,
|
|
473
|
+
async run(ctx, args) {
|
|
474
|
+
const id = await resolveSite(ctx, args.site);
|
|
475
|
+
const ref = String(args.issue ?? '').trim();
|
|
476
|
+
const issues = await ctx.api.issuesOf(id, 'all');
|
|
477
|
+
const byId = issues.find((i) => i.id === ref);
|
|
478
|
+
const byCode = issues.filter((i) => i.code === ref && i.status === 'open');
|
|
479
|
+
const issue = byId ?? (byCode.length === 1 ? byCode[0] : null);
|
|
480
|
+
if (!issue)
|
|
481
|
+
return byCode.length > 1 ? `${byCode.length} open issues carry code ${ref}; pass the id of the one you mean: ${byCode.map((i) => i.id).join(', ')}.` : `No issue “${ref}” on site ${id}. Use get_compliance_status.`;
|
|
482
|
+
const reason = str(args.reason);
|
|
483
|
+
if (!reason)
|
|
484
|
+
throw new CliError('usage', 'A `reason` is required — it is kept with the issue.', 2);
|
|
485
|
+
if (args.confirm !== true)
|
|
486
|
+
return plan(`Would suppress issue ${issue.id} (${issue.code}, ${issue.severity}) on site ${id}.`, jsonDiff({ status: issue.status, suppress_reason: issue.suppress_reason ?? null }, { status: 'suppressed', suppress_reason: reason }, 'current', 'proposed'), ['', 'Suppressing does not make the finding lawful; it records that a person decided the rule does not apply here, and why.']);
|
|
487
|
+
const out = await ctx.api.suppressIssue(issue.id, reason);
|
|
488
|
+
return `Issue ${out.id} (${out.code}) is now ${out.status}: “${out.suppress_reason}”.`;
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: 'unsuppress_issue',
|
|
493
|
+
...meta('unsuppress_issue'),
|
|
494
|
+
description: 'Reopen a suppressed compliance issue. Two-step.',
|
|
495
|
+
inputSchema: { site: SITE, issue: z.string().describe('issue id'), confirm: CONFIRM },
|
|
496
|
+
readOnlyHint: false,
|
|
497
|
+
async run(ctx, args) {
|
|
498
|
+
const id = await resolveSite(ctx, args.site);
|
|
499
|
+
const issue = (await ctx.api.issuesOf(id, 'suppressed')).find((i) => i.id === String(args.issue));
|
|
500
|
+
if (!issue)
|
|
501
|
+
return `No suppressed issue “${args.issue}” on site ${id}.`;
|
|
502
|
+
if (args.confirm !== true)
|
|
503
|
+
return plan(`Would reopen issue ${issue.id} (${issue.code}).`, jsonDiff({ status: 'suppressed', suppress_reason: issue.suppress_reason ?? null }, { status: 'open', suppress_reason: null }, 'current', 'proposed'));
|
|
504
|
+
const out = await ctx.api.unsuppressIssue(issue.id);
|
|
505
|
+
return `Issue ${out.id} (${out.code}) is now ${out.status}.`;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
// ---- versions -------------------------------------------------------------------------------
|
|
509
|
+
{
|
|
510
|
+
name: 'promote_version',
|
|
511
|
+
...meta('promote_version'),
|
|
512
|
+
description: 'Publish a preview version to production, as it is. `confirm:false` shows the diff between the current production version and the preview version.',
|
|
513
|
+
inputSchema: { site: SITE, number: z.number().int().min(1).describe('the preview version number'), confirm: CONFIRM },
|
|
514
|
+
readOnlyHint: false,
|
|
515
|
+
async run(ctx, args) {
|
|
516
|
+
const id = await resolveSite(ctx, args.site);
|
|
517
|
+
const versions = await ctx.api.versions(id);
|
|
518
|
+
const target = versions.find((v) => v.number === Number(args.number));
|
|
519
|
+
if (!target)
|
|
520
|
+
return `Site ${id} has no v${args.number}. Versions: ${versions.map((v) => `v${v.number} (${v.environment ?? 'production'})`).join(', ') || 'none'}.`;
|
|
521
|
+
if ((target.environment ?? 'production') !== 'preview')
|
|
522
|
+
return `v${target.number} is already a ${target.environment ?? 'production'} version; promote takes a preview version.`;
|
|
523
|
+
const live = versions.filter((v) => (v.environment ?? 'production') === 'production').sort((a, b) => b.number - a.number)[0] ?? null;
|
|
524
|
+
const targetFull = await ctx.api.version(target.id);
|
|
525
|
+
const liveFull = live ? await ctx.api.version(live.id) : null;
|
|
526
|
+
const diff = jsonDiff(liveFull?.config ?? {}, targetFull.config ?? {}, `production v${live?.number ?? '—'}`, `preview v${target.number}`);
|
|
527
|
+
if (args.confirm !== true)
|
|
528
|
+
return plan(`Would promote preview v${target.number} of site ${id} to production${live ? `, replacing v${live.number}` : ''}.`, diff, ['', 'The publish gates (plan features, lint) run again on promotion.']);
|
|
529
|
+
const out = await ctx.api.promote(target.id);
|
|
530
|
+
return [`Promoted v${target.number} of site ${id} to production.`, jsonBlock(out)].join('\n');
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
// ---- templates ------------------------------------------------------------------------------
|
|
534
|
+
{
|
|
535
|
+
name: 'list_templates',
|
|
536
|
+
...meta('list_templates'),
|
|
537
|
+
description: 'Banner templates: the built-in ones and the workspace’s own.',
|
|
538
|
+
inputSchema: {},
|
|
539
|
+
readOnlyHint: true,
|
|
540
|
+
async run(ctx) {
|
|
541
|
+
const items = await ctx.api.templates();
|
|
542
|
+
if (!items.length)
|
|
543
|
+
return 'No templates.';
|
|
544
|
+
return items.map((t) => `- ${t.name} id ${t.id} (${t.org_id ? 'workspace' : 'built-in'})${t.description ? `\n ${t.description}` : ''}`).join('\n');
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: 'get_template',
|
|
549
|
+
...meta('get_template'),
|
|
550
|
+
description: 'One template with its full BannerConfig.',
|
|
551
|
+
inputSchema: { template: z.string().describe('template id or name') },
|
|
552
|
+
readOnlyHint: true,
|
|
553
|
+
async run(ctx, args) {
|
|
554
|
+
const t = await templateByRef(ctx, String(args.template));
|
|
555
|
+
if (!t)
|
|
556
|
+
return `No template “${args.template}”. Use list_templates.`;
|
|
557
|
+
const full = await ctx.api.template(t.id);
|
|
558
|
+
return [`${full.name} (${full.org_id ? 'workspace' : 'built-in'}) · id ${full.id}`, jsonBlock(full.config ?? null)].join('\n');
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: 'apply_template',
|
|
563
|
+
...meta('apply_template'),
|
|
564
|
+
description: 'Apply a template to one or more site drafts (never publishes). `groups` limits what is copied: layout, theme, texts, regions, consent_mode, behaviour. Two-step.',
|
|
565
|
+
inputSchema: {
|
|
566
|
+
template: z.string().describe('template id or name'),
|
|
567
|
+
sites: z.array(z.string()).optional().describe('site ids/domains/names; default: the resolved site'),
|
|
568
|
+
site: SITE,
|
|
569
|
+
groups: z.array(z.enum(['layout', 'theme', 'texts', 'regions', 'consent_mode', 'behaviour'])).optional(),
|
|
570
|
+
env: ENV.optional().describe('which draft to write, default preview'),
|
|
571
|
+
confirm: CONFIRM,
|
|
572
|
+
},
|
|
573
|
+
readOnlyHint: false,
|
|
574
|
+
async run(ctx, args) {
|
|
575
|
+
const t = await templateByRef(ctx, String(args.template));
|
|
576
|
+
if (!t)
|
|
577
|
+
return `No template “${args.template}”. Use list_templates.`;
|
|
578
|
+
const refs = list(args.sites);
|
|
579
|
+
const siteIds = refs?.length ? await Promise.all(refs.map((r) => resolveSite(ctx, r))) : [await resolveSite(ctx, args.site)];
|
|
580
|
+
const env = str(args.env) ?? 'preview';
|
|
581
|
+
const groups = list(args.groups);
|
|
582
|
+
if (args.confirm !== true) {
|
|
583
|
+
const full = await ctx.api.template(t.id);
|
|
584
|
+
const cfg = (full.config ?? {});
|
|
585
|
+
const picked = groups?.length ? Object.fromEntries(groups.map((g) => [g, cfg[g]])) : cfg;
|
|
586
|
+
return plan(`Would apply template “${t.name}” (${groups?.length ? groups.join(', ') : 'all groups'}) to the ${env} draft of ${siteIds.length} site(s): ${siteIds.join(', ')}.`, jsonDiff({}, picked, 'nothing', `template ${t.name}`), ['', 'Texts are merged per language and category the site already has; nothing is published.']);
|
|
587
|
+
}
|
|
588
|
+
const results = await ctx.api.applyTemplate(t.id, { site_ids: siteIds, groups: groups?.length ? groups : undefined, env });
|
|
589
|
+
return results.map((r) => `- ${r.site_id}: ${r.ok ? 'draft updated' : `FAILED — ${r.error ?? 'unknown error'}`}${r.ok && r.lint?.issues?.length ? ` (lint: ${r.lint.issues.map((i) => i.code).join(', ')})` : ''}`).join('\n') + '\n\nNothing is published; use push_config or the dashboard to publish.';
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
name: 'save_template',
|
|
594
|
+
...meta('save_template'),
|
|
595
|
+
description: 'Save a workspace template from a site’s draft (or from a full BannerConfig), or update an existing workspace template. Needs the shared_templates feature. Two-step.',
|
|
596
|
+
inputSchema: {
|
|
597
|
+
name: z.string().min(1).max(80),
|
|
598
|
+
site: SITE.describe('site whose draft becomes the template (when no config is given)'),
|
|
599
|
+
env: ENV.optional().describe('which draft, default production'),
|
|
600
|
+
config: z.record(z.unknown()).optional().describe('a full BannerConfig instead of a site draft'),
|
|
601
|
+
template: z.string().optional().describe('id or name of an existing workspace template to update'),
|
|
602
|
+
confirm: CONFIRM,
|
|
603
|
+
},
|
|
604
|
+
readOnlyHint: false,
|
|
605
|
+
async run(ctx, args) {
|
|
606
|
+
const name = str(args.name);
|
|
607
|
+
if (!name)
|
|
608
|
+
throw new CliError('usage', 'Pass `name`.', 2);
|
|
609
|
+
const existing = args.template ? await templateByRef(ctx, String(args.template)) : null;
|
|
610
|
+
if (args.template && !existing)
|
|
611
|
+
return `No template “${args.template}”. Use list_templates.`;
|
|
612
|
+
if (existing && !existing.org_id)
|
|
613
|
+
return `“${existing.name}” is built-in and cannot be changed; save a copy under a new name instead.`;
|
|
614
|
+
let config;
|
|
615
|
+
let source;
|
|
616
|
+
if (args.config && typeof args.config === 'object') {
|
|
617
|
+
config = args.config;
|
|
618
|
+
source = 'the given config';
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const id = await resolveSite(ctx, args.site);
|
|
622
|
+
const env = str(args.env) ?? 'production';
|
|
623
|
+
config = (await ctx.api.getDraft(id, env)).config;
|
|
624
|
+
source = `the ${env} draft of site ${id}`;
|
|
625
|
+
}
|
|
626
|
+
if (args.confirm !== true) {
|
|
627
|
+
const before = existing ? ((await ctx.api.template(existing.id)).config ?? {}) : {};
|
|
628
|
+
return plan(`Would ${existing ? `update template “${existing.name}”` : `create template “${name}”`} from ${source}.`, jsonDiff(before, config, existing ? `template ${existing.name}` : 'no template', name), ['', 'Requires the shared_templates plan feature (402 otherwise).']);
|
|
629
|
+
}
|
|
630
|
+
const saved = existing
|
|
631
|
+
? await ctx.api.updateTemplate(existing.id, { name, config: config })
|
|
632
|
+
: await ctx.api.createTemplate({ name, config: config });
|
|
633
|
+
return `${existing ? 'Updated' : 'Saved'} template “${saved.name}” (${saved.id}). Apply it with apply_template.`;
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'delete_template',
|
|
638
|
+
...meta('delete_template'),
|
|
639
|
+
description: 'Delete a workspace template (built-ins cannot be deleted). Two-step; destructive.',
|
|
640
|
+
inputSchema: { template: z.string().describe('template id or name'), confirm: CONFIRM },
|
|
641
|
+
readOnlyHint: false,
|
|
642
|
+
destructiveHint: true,
|
|
643
|
+
async run(ctx, args) {
|
|
644
|
+
const t = await templateByRef(ctx, String(args.template));
|
|
645
|
+
if (!t)
|
|
646
|
+
return `No template “${args.template}”.`;
|
|
647
|
+
if (!t.org_id)
|
|
648
|
+
return `“${t.name}” is built-in and cannot be deleted.`;
|
|
649
|
+
if (args.confirm !== true)
|
|
650
|
+
return plan(`Would DELETE workspace template “${t.name}” (${t.id}).`, jsonDiff({ id: t.id, name: t.name }, {}, 'template', 'deleted'), ['', 'Sites it was applied to keep their drafts; only the template goes.']);
|
|
651
|
+
await ctx.api.deleteTemplate(t.id);
|
|
652
|
+
return `Template “${t.name}” deleted.`;
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
// ---- webhooks -------------------------------------------------------------------------------
|
|
656
|
+
{
|
|
657
|
+
name: 'list_webhooks',
|
|
658
|
+
...meta('list_webhooks'),
|
|
659
|
+
description: 'Outbound webhook endpoints of the workspace (never their secrets), with last delivery status.',
|
|
660
|
+
inputSchema: { site: SITE.describe('only endpoints of this site') },
|
|
661
|
+
readOnlyHint: true,
|
|
662
|
+
async run(ctx, args) {
|
|
663
|
+
const items = await ctx.api.webhooks(args.site ? await resolveSite(ctx, args.site) : undefined);
|
|
664
|
+
if (!items.length)
|
|
665
|
+
return 'No webhook endpoints.';
|
|
666
|
+
return items.map((e) => `- ${e.url} id ${e.id}\n events ${(e.events ?? []).join(', ')} · ${e.enabled === false ? 'disabled' : 'enabled'} · scope ${e.site_id ? `site ${e.site_id}` : 'workspace'} · last delivery ${e.last_delivery_at ?? '—'} ${e.last_status ?? ''}`).join('\n');
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: 'create_webhook',
|
|
671
|
+
...meta('create_webhook'),
|
|
672
|
+
description: 'Create a webhook endpoint (https only). The signing secret is returned once, in the confirmed answer, and can only be rotated afterwards. Two-step.',
|
|
673
|
+
inputSchema: {
|
|
674
|
+
url: z.string().url(),
|
|
675
|
+
events: z.array(z.string()).optional().describe('event types, default ["*"]'),
|
|
676
|
+
site: SITE.describe('restrict to one site; default the whole workspace'),
|
|
677
|
+
description: z.string().max(500).optional(),
|
|
678
|
+
confirm: CONFIRM,
|
|
679
|
+
},
|
|
680
|
+
readOnlyHint: false,
|
|
681
|
+
async run(ctx, args) {
|
|
682
|
+
const url = str(args.url);
|
|
683
|
+
if (!url || !/^https:\/\//i.test(url))
|
|
684
|
+
throw new CliError('usage', 'Pass an https `url`.', 2);
|
|
685
|
+
const events = list(args.events)?.length ? list(args.events) : ['*'];
|
|
686
|
+
const siteId = args.site ? await resolveSite(ctx, args.site) : null;
|
|
687
|
+
const body = { url, events, site_id: siteId, description: str(args.description) ?? null };
|
|
688
|
+
if (args.confirm !== true)
|
|
689
|
+
return plan(`Would create a webhook endpoint for ${url}.`, jsonDiff({}, body, 'no endpoint', 'new endpoint'), ['', 'Requires the webhooks plan feature. The secret is shown once, in the confirmed answer — store it in the receiver at once.']);
|
|
690
|
+
const created = await ctx.api.createWebhook(body);
|
|
691
|
+
const endpoint = created.endpoint ?? created;
|
|
692
|
+
return [`Endpoint ${endpoint.id} created for ${url}.`, created.secret ? `Signing secret (shown once): ${created.secret}` : 'The gateway returned no secret.', 'Verify deliveries with the Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature).'].join('\n');
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: 'update_webhook',
|
|
697
|
+
...meta('update_webhook'),
|
|
698
|
+
description: 'Change a webhook endpoint: url, events, description, enabled. Two-step.',
|
|
699
|
+
inputSchema: { webhook: z.string().describe('endpoint id or url'), url: z.string().url().optional(), events: z.array(z.string()).optional(), description: z.string().nullable().optional(), enabled: z.boolean().optional(), confirm: CONFIRM },
|
|
700
|
+
readOnlyHint: false,
|
|
701
|
+
async run(ctx, args) {
|
|
702
|
+
const hook = await webhookByRef(ctx, String(args.webhook));
|
|
703
|
+
if (!hook)
|
|
704
|
+
return `No webhook endpoint “${args.webhook}”. Use list_webhooks.`;
|
|
705
|
+
const patch = compact({ url: str(args.url), events: list(args.events), enabled: bool(args.enabled), description: 'description' in args ? (args.description === null ? null : str(args.description) ?? null) : undefined });
|
|
706
|
+
if (!Object.keys(patch).length)
|
|
707
|
+
throw new CliError('usage', 'Nothing to change: pass url, events, description or enabled.', 2);
|
|
708
|
+
const view = (h) => ({ url: h.url, events: h.events, description: h.description, enabled: h.enabled });
|
|
709
|
+
if (args.confirm !== true)
|
|
710
|
+
return plan(`Would update endpoint ${hook.id}.`, jsonDiff(view(hook), { ...view(hook), ...patch }, 'current', 'proposed'));
|
|
711
|
+
const updated = await ctx.api.request('PATCH', `/webhooks/${hook.id}`, { body: patch });
|
|
712
|
+
return [`Endpoint ${hook.id} updated.`, jsonBlock(updated)].join('\n');
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: 'delete_webhook',
|
|
717
|
+
...meta('delete_webhook'),
|
|
718
|
+
description: 'Delete a webhook endpoint and its delivery history. Two-step; destructive.',
|
|
719
|
+
inputSchema: { webhook: z.string().describe('endpoint id or url'), confirm: CONFIRM },
|
|
720
|
+
readOnlyHint: false,
|
|
721
|
+
destructiveHint: true,
|
|
722
|
+
async run(ctx, args) {
|
|
723
|
+
const hook = await webhookByRef(ctx, String(args.webhook));
|
|
724
|
+
if (!hook)
|
|
725
|
+
return `No webhook endpoint “${args.webhook}”.`;
|
|
726
|
+
if (args.confirm !== true)
|
|
727
|
+
return plan(`Would DELETE endpoint ${hook.id} (${hook.url}) and its deliveries.`, jsonDiff({ id: hook.id, url: hook.url, events: hook.events }, {}, 'endpoint', 'deleted'));
|
|
728
|
+
await ctx.api.deleteWebhook(hook.id);
|
|
729
|
+
return `Endpoint ${hook.id} (${hook.url}) deleted.`;
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
// ---- alert channels -------------------------------------------------------------------------
|
|
733
|
+
{
|
|
734
|
+
name: 'list_alert_channels',
|
|
735
|
+
...meta('list_alert_channels'),
|
|
736
|
+
description: 'Where alerts are delivered (e-mail, Slack, webhook) and the rules on each channel. Secrets are never returned.',
|
|
737
|
+
inputSchema: {},
|
|
738
|
+
readOnlyHint: true,
|
|
739
|
+
async run(ctx) {
|
|
740
|
+
const items = await channels(ctx);
|
|
741
|
+
if (!items.length)
|
|
742
|
+
return 'No alert channels. Alerts still appear in the inbox (list_alerts).';
|
|
743
|
+
return items.map((c) => `- ${c.name} id ${c.id} ${c.kind} · ${c.enabled ? 'enabled' : 'disabled'} · ${c.verified_at ? 'verified' : 'unverified'} · scope ${c.site_id ? `site ${c.site_id}` : 'workspace'}\n config ${JSON.stringify(c.config)} · ${(c.rules ?? []).length} rule(s)`).join('\n');
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: 'create_alert_channel',
|
|
748
|
+
...meta('create_alert_channel'),
|
|
749
|
+
description: 'Create an alert channel: email ({config:{to:[…]}}), slack_webhook or webhook (the URL goes in `secret`, write-only). Two-step.',
|
|
750
|
+
inputSchema: {
|
|
751
|
+
kind: z.enum(['email', 'slack_webhook', 'webhook']),
|
|
752
|
+
name: z.string().min(1).max(80),
|
|
753
|
+
config: z.record(z.unknown()).optional().describe('email: {to:["a@b.eu"]}; slack/webhook: optional {channel}'),
|
|
754
|
+
secret: z.string().optional().describe('the Slack incoming-webhook URL or the webhook URL; stored write-only'),
|
|
755
|
+
site: SITE.describe('scope the channel to one site; default the workspace'),
|
|
756
|
+
confirm: CONFIRM,
|
|
757
|
+
},
|
|
758
|
+
readOnlyHint: false,
|
|
759
|
+
async run(ctx, args) {
|
|
760
|
+
const kind = str(args.kind);
|
|
761
|
+
const name = str(args.name);
|
|
762
|
+
if (!kind || !name)
|
|
763
|
+
throw new CliError('usage', 'Pass `kind` and `name`.', 2);
|
|
764
|
+
const config = (args.config && typeof args.config === 'object' ? args.config : {});
|
|
765
|
+
const siteId = args.site ? await resolveSite(ctx, args.site) : null;
|
|
766
|
+
const shown = { kind, name, config, site_id: siteId, secret: args.secret ? '(set, not shown)' : '(none)' };
|
|
767
|
+
if (args.confirm !== true)
|
|
768
|
+
return plan(`Would create ${kind} alert channel “${name}”.`, jsonDiff({}, shown, 'no channel', 'new channel'), ['', 'Slack and webhook channels need the matching plan feature; e-mail is available everywhere.']);
|
|
769
|
+
const created = await ctx.api.request('POST', '/alert-channels', { body: { kind, name, config, secret: str(args.secret) ?? null, site_id: siteId } });
|
|
770
|
+
return [`Alert channel ${created.id} (“${created.name}”, ${created.kind}) created.`, 'Rules decide which alerts go there; the dashboard’s Alerts page sets them.'].join('\n');
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: 'update_alert_channel',
|
|
775
|
+
...meta('update_alert_channel'),
|
|
776
|
+
description: 'Rename, enable/disable, or change the config or secret of an alert channel. Two-step.',
|
|
777
|
+
inputSchema: { channel: z.string().describe('channel id or name'), name: z.string().optional(), enabled: z.boolean().optional(), config: z.record(z.unknown()).optional(), secret: z.string().optional().describe('rotating it clears verified_at'), confirm: CONFIRM },
|
|
778
|
+
readOnlyHint: false,
|
|
779
|
+
async run(ctx, args) {
|
|
780
|
+
const ch = await channelByRef(ctx, String(args.channel));
|
|
781
|
+
if (!ch)
|
|
782
|
+
return `No alert channel “${args.channel}”. Use list_alert_channels.`;
|
|
783
|
+
const patch = compact({ name: str(args.name), enabled: bool(args.enabled), config: args.config && typeof args.config === 'object' ? args.config : undefined, secret: str(args.secret) });
|
|
784
|
+
if (!Object.keys(patch).length)
|
|
785
|
+
throw new CliError('usage', 'Nothing to change: pass name, enabled, config or secret.', 2);
|
|
786
|
+
const view = (c) => ({ name: c.name, enabled: c.enabled, config: c.config });
|
|
787
|
+
const after = { ...view(ch), ...compact({ name: patch.name, enabled: patch.enabled, config: patch.config }) };
|
|
788
|
+
if (args.confirm !== true)
|
|
789
|
+
return plan(`Would update alert channel “${ch.name}”.`, jsonDiff(view(ch), after, 'current', 'proposed'), patch.secret ? ['', 'The secret would be replaced (it is never shown) and the channel becomes unverified until the next delivery succeeds.'] : []);
|
|
790
|
+
const updated = await ctx.api.request('PATCH', `/alert-channels/${ch.id}`, { body: patch });
|
|
791
|
+
return [`Alert channel ${updated.id} updated.`, jsonBlock(view(updated))].join('\n');
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: 'delete_alert_channel',
|
|
796
|
+
...meta('delete_alert_channel'),
|
|
797
|
+
description: 'Delete an alert channel and its rules. Two-step; destructive.',
|
|
798
|
+
inputSchema: { channel: z.string().describe('channel id or name'), confirm: CONFIRM },
|
|
799
|
+
readOnlyHint: false,
|
|
800
|
+
destructiveHint: true,
|
|
801
|
+
async run(ctx, args) {
|
|
802
|
+
const ch = await channelByRef(ctx, String(args.channel));
|
|
803
|
+
if (!ch)
|
|
804
|
+
return `No alert channel “${args.channel}”.`;
|
|
805
|
+
if (args.confirm !== true)
|
|
806
|
+
return plan(`Would DELETE alert channel “${ch.name}” (${ch.kind}) and its ${(ch.rules ?? []).length} rule(s).`, jsonDiff({ id: ch.id, name: ch.name, kind: ch.kind }, {}, 'channel', 'deleted'));
|
|
807
|
+
await ctx.api.request('DELETE', `/alert-channels/${ch.id}`);
|
|
808
|
+
return `Alert channel “${ch.name}” deleted.`;
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
// ---- usage and scheduled exports -----------------------------------------------------------
|
|
812
|
+
{
|
|
813
|
+
name: 'get_usage',
|
|
814
|
+
...meta('get_usage'),
|
|
815
|
+
description: 'Usage meters of the workspace for a month: pageviews, sites, seats, consents, scan pages, exports — used, limit and percentage.',
|
|
816
|
+
inputSchema: { period: z.string().optional().describe('YYYY-MM, default this month') },
|
|
817
|
+
readOnlyHint: true,
|
|
818
|
+
async run(ctx, args) {
|
|
819
|
+
const u = await ctx.api.usage(str(args.period));
|
|
820
|
+
const meters = u.meters ?? [];
|
|
821
|
+
return [`Usage for ${u.period ?? 'this month'} (plan ${u.plan ?? ctx.me?.org?.plan ?? '—'})`, ...meters.map((m) => `- ${m.metric}: ${m.used}${m.limit == null || m.limit < 0 ? ' (unlimited)' : ` / ${m.limit}${m.pct != null ? ` (${m.pct}%)` : ''}`}`)].join('\n');
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
name: 'list_export_destinations',
|
|
826
|
+
...meta('list_export_destinations'),
|
|
827
|
+
description: 'Customer-owned S3 buckets scheduled exports are delivered to (never the secret key).',
|
|
828
|
+
inputSchema: {},
|
|
829
|
+
readOnlyHint: true,
|
|
830
|
+
async run(ctx) {
|
|
831
|
+
const r = await ctx.api.request('GET', '/export-destinations');
|
|
832
|
+
const items = r.items ?? [];
|
|
833
|
+
if (!items.length)
|
|
834
|
+
return 'No export destinations. Register a bucket in the dashboard under Consent log → Exports.';
|
|
835
|
+
return items.map((d) => `- ${d.name} id ${d.id} ${d.endpoint}/${d.bucket}${d.prefix ? `/${d.prefix}` : ''} · ${d.verified_at ? 'verified' : 'unverified'}${d.last_error ? ` · last error: ${d.last_error}` : ''}`).join('\n');
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: 'list_export_schedules',
|
|
840
|
+
...meta('list_export_schedules'),
|
|
841
|
+
description: 'Recurring proof exports: kind, cadence, destination, next and last run.',
|
|
842
|
+
inputSchema: { site: SITE.describe('only schedules of this site') },
|
|
843
|
+
readOnlyHint: true,
|
|
844
|
+
async run(ctx, args) {
|
|
845
|
+
const items = await exportSchedules(ctx, args.site ? await resolveSite(ctx, args.site) : undefined);
|
|
846
|
+
if (!items.length)
|
|
847
|
+
return 'No scheduled exports.';
|
|
848
|
+
return items.map((s) => `- ${s.name ?? s.kind} id ${s.id} ${s.kind} ${s.cadence} → destination ${s.destination_name ?? s.destination_id} · ${s.enabled ? 'enabled' : 'disabled'} · next ${s.next_run_at ?? '—'} · last ${s.last_run_at ?? '—'} ${s.last_status ?? ''}`).join('\n');
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
name: 'create_export_schedule',
|
|
853
|
+
...meta('create_export_schedule'),
|
|
854
|
+
description: 'Schedule a recurring proof export of a site to a registered destination. Needs the scheduled_exports feature. Two-step.',
|
|
855
|
+
inputSchema: {
|
|
856
|
+
site: SITE,
|
|
857
|
+
destination: z.string().describe('destination id or name'),
|
|
858
|
+
kind: z.enum(['consent_jsonl', 'consent_csv', 'audit_bundle']),
|
|
859
|
+
cadence: z.enum(['daily', 'weekly', 'monthly']),
|
|
860
|
+
env: ENV.optional(),
|
|
861
|
+
hour_utc: z.number().int().min(0).max(23).optional(),
|
|
862
|
+
day_of_week: z.number().int().min(0).max(6).optional().describe('weekly: 0 = Sunday'),
|
|
863
|
+
day_of_month: z.number().int().min(1).max(28).optional(),
|
|
864
|
+
lookback_days: z.number().int().min(1).max(395).optional(),
|
|
865
|
+
name: z.string().max(80).optional(),
|
|
866
|
+
confirm: CONFIRM,
|
|
867
|
+
},
|
|
868
|
+
readOnlyHint: false,
|
|
869
|
+
async run(ctx, args) {
|
|
870
|
+
const siteId = await resolveSite(ctx, args.site);
|
|
871
|
+
const dests = (await ctx.api.request('GET', '/export-destinations')).items ?? [];
|
|
872
|
+
const ref = String(args.destination ?? '').trim();
|
|
873
|
+
const dest = dests.find((d) => d.id === ref) ?? dests.find((d) => String(d.name).toLowerCase() === ref.toLowerCase());
|
|
874
|
+
if (!dest)
|
|
875
|
+
return `No export destination “${ref}”. Known: ${dests.map((d) => `${d.name} (${d.id})`).join(', ') || 'none'}.`;
|
|
876
|
+
const body = compact({ site_id: siteId, destination_id: String(dest.id), kind: str(args.kind), cadence: str(args.cadence), environment: str(args.env), hour_utc: num(args.hour_utc), day_of_week: num(args.day_of_week), day_of_month: num(args.day_of_month), lookback_days: num(args.lookback_days), name: str(args.name) });
|
|
877
|
+
if (args.confirm !== true)
|
|
878
|
+
return plan(`Would schedule a ${body.cadence} ${body.kind} export of site ${siteId} to “${dest.name}”.`, jsonDiff({}, body, 'no schedule', 'new schedule'), ['', dest.verified_at ? 'The destination is verified.' : 'Note: the destination is not verified yet; runs will fail until a probe succeeds.']);
|
|
879
|
+
const created = await ctx.api.request('POST', '/export-schedules', { body });
|
|
880
|
+
return [`Export schedule ${created.id} created.`, jsonBlock(created)].join('\n');
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
name: 'update_export_schedule',
|
|
885
|
+
...meta('update_export_schedule'),
|
|
886
|
+
description: 'Change a scheduled export: kind, cadence, timing, window, destination, enabled. Two-step.',
|
|
887
|
+
inputSchema: {
|
|
888
|
+
schedule: z.string().describe('schedule id or name'),
|
|
889
|
+
destination: z.string().optional(),
|
|
890
|
+
kind: z.enum(['consent_jsonl', 'consent_csv', 'audit_bundle']).optional(),
|
|
891
|
+
cadence: z.enum(['daily', 'weekly', 'monthly']).optional(),
|
|
892
|
+
env: ENV.optional(),
|
|
893
|
+
hour_utc: z.number().int().min(0).max(23).optional(),
|
|
894
|
+
day_of_week: z.number().int().min(0).max(6).optional(),
|
|
895
|
+
day_of_month: z.number().int().min(1).max(28).optional(),
|
|
896
|
+
lookback_days: z.number().int().min(1).max(395).optional(),
|
|
897
|
+
enabled: z.boolean().optional(),
|
|
898
|
+
name: z.string().max(80).nullable().optional(),
|
|
899
|
+
confirm: CONFIRM,
|
|
900
|
+
},
|
|
901
|
+
readOnlyHint: false,
|
|
902
|
+
async run(ctx, args) {
|
|
903
|
+
const all = await exportSchedules(ctx);
|
|
904
|
+
const ref = String(args.schedule ?? '').trim();
|
|
905
|
+
const s = all.find((x) => x.id === ref) ?? all.find((x) => String(x.name ?? '').toLowerCase() === ref.toLowerCase());
|
|
906
|
+
if (!s)
|
|
907
|
+
return `No export schedule “${ref}”. Use list_export_schedules.`;
|
|
908
|
+
const patch = compact({ destination_id: str(args.destination), kind: str(args.kind), cadence: str(args.cadence), environment: str(args.env), hour_utc: num(args.hour_utc), day_of_week: num(args.day_of_week), day_of_month: num(args.day_of_month), lookback_days: num(args.lookback_days), enabled: bool(args.enabled), name: 'name' in args ? (args.name === null ? null : str(args.name)) : undefined });
|
|
909
|
+
if (!Object.keys(patch).length)
|
|
910
|
+
throw new CliError('usage', 'Nothing to change.', 2);
|
|
911
|
+
const view = (x) => ({ kind: x.kind, cadence: x.cadence, environment: x.environment, hour_utc: x.hour_utc, day_of_week: x.day_of_week, day_of_month: x.day_of_month, lookback_days: x.lookback_days, enabled: x.enabled, name: x.name ?? null, destination_id: x.destination_id });
|
|
912
|
+
if (args.confirm !== true)
|
|
913
|
+
return plan(`Would update export schedule ${s.id}.`, jsonDiff(view(s), { ...view(s), ...patch }, 'current', 'proposed'));
|
|
914
|
+
const updated = await ctx.api.request('PATCH', `/export-schedules/${s.id}`, { body: patch });
|
|
915
|
+
return [`Export schedule ${updated.id} updated.`, jsonBlock(view(updated))].join('\n');
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: 'delete_export_schedule',
|
|
920
|
+
...meta('delete_export_schedule'),
|
|
921
|
+
description: 'Delete a scheduled export (already delivered files stay in the bucket). Two-step; destructive.',
|
|
922
|
+
inputSchema: { schedule: z.string().describe('schedule id or name'), confirm: CONFIRM },
|
|
923
|
+
readOnlyHint: false,
|
|
924
|
+
destructiveHint: true,
|
|
925
|
+
async run(ctx, args) {
|
|
926
|
+
const all = await exportSchedules(ctx);
|
|
927
|
+
const ref = String(args.schedule ?? '').trim();
|
|
928
|
+
const s = all.find((x) => x.id === ref) ?? all.find((x) => String(x.name ?? '').toLowerCase() === ref.toLowerCase());
|
|
929
|
+
if (!s)
|
|
930
|
+
return `No export schedule “${ref}”.`;
|
|
931
|
+
if (args.confirm !== true)
|
|
932
|
+
return plan(`Would DELETE export schedule ${s.id} (${s.kind} ${s.cadence}).`, jsonDiff({ id: s.id, kind: s.kind, cadence: s.cadence }, {}, 'schedule', 'deleted'));
|
|
933
|
+
await ctx.api.request('DELETE', `/export-schedules/${s.id}`);
|
|
934
|
+
return `Export schedule ${s.id} deleted.`;
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
];
|
|
938
|
+
/** What to publish for a domain to verify, in one line. */
|
|
939
|
+
function instructions(d) {
|
|
940
|
+
return d.verification_method === 'meta'
|
|
941
|
+
? `<meta name="cookiecrumbs-verification" content="${d.verification_token}"> in the <head> of https://${d.hostname}/`
|
|
942
|
+
: `DNS TXT record _cookiecrumbs.${d.hostname} = "cookiecrumbs-verification=${d.verification_token}"`;
|
|
943
|
+
}
|