@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,97 @@
|
|
|
1
|
+
import { usage } from "../errors.js";
|
|
2
|
+
import { human, bold, dim, yellow } from "../output.js";
|
|
3
|
+
import { formatDate, parseCsvList, parseIntOpt } from "../util.js";
|
|
4
|
+
import { api, finish, parseOnOff, siteIdOf } from "./_shared.js";
|
|
5
|
+
const CADENCES = ['monthly', 'weekly', 'daily'];
|
|
6
|
+
const STATES = ['no_interaction', 'reject_all', 'accept_all'];
|
|
7
|
+
function describe(s) {
|
|
8
|
+
return [
|
|
9
|
+
`${bold('Cadence')} ${s.cadence}${s.paused ? ' ' + yellow('(paused)') : ''}`,
|
|
10
|
+
`${bold('Next run')} ${s.paused ? dim('while paused, none') : formatDate(s.next_run_at)}`,
|
|
11
|
+
`${bold('Page cap')} ${s.page_cap}`,
|
|
12
|
+
`${bold('States')} ${(s.states ?? []).join(', ')}`,
|
|
13
|
+
`${bold('Start URLs')} ${(s.start_urls ?? []).join(', ') || dim('the home page')}`,
|
|
14
|
+
`${bold('Only paths')} ${(s.include_patterns ?? []).join(', ') || dim('everything')}`,
|
|
15
|
+
`${bold('Never paths')} ${(s.exclude_patterns ?? []).join(', ') || dim('—')}`,
|
|
16
|
+
`${bold('robots.txt')} ${s.respect_robots ? 'obeyed' : yellow('ignored')}`,
|
|
17
|
+
`${bold('Last run')} ${s.last_run_scan_id ?? dim('no scheduled scan has run yet')}`,
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
/** A list option: "a,b" sets, "" (or "none") clears. */
|
|
21
|
+
const listOpt = (v) => (v === undefined ? undefined : v.trim() === '' || v.trim().toLowerCase() === 'none' ? [] : (parseCsvList(v) ?? []));
|
|
22
|
+
export function register(program) {
|
|
23
|
+
const schedule = program.command('schedule').description('Scheduled scans of the linked site');
|
|
24
|
+
schedule
|
|
25
|
+
.command('show', { isDefault: true })
|
|
26
|
+
.description('The schedule: cadence, next run, page cap, states, crawl scope')
|
|
27
|
+
.option('--site <id>', 'site id (default: the linked site)')
|
|
28
|
+
.action(async (opts) => {
|
|
29
|
+
const s = await api().scanSchedule(siteIdOf(opts.site));
|
|
30
|
+
finish({ schedule: s }, () => human.message(describe(s)));
|
|
31
|
+
});
|
|
32
|
+
schedule
|
|
33
|
+
.command('set')
|
|
34
|
+
.description('Change the schedule (only the options you pass change; the plan may cap cadence and pages)')
|
|
35
|
+
.option('--site <id>', 'site id (default: the linked site)')
|
|
36
|
+
.option('--cadence <cadence>', CADENCES.join(', '))
|
|
37
|
+
.option('--pages <n>', 'page cap per scan', parseIntOpt('--pages'))
|
|
38
|
+
.option('--states <list>', `comma-separated: ${STATES.join(', ')}`)
|
|
39
|
+
.option('--start-urls <list>', 'comma-separated start addresses ("none" = the home page)')
|
|
40
|
+
.option('--include <list>', 'comma-separated path patterns to crawl, e.g. /blog/** ("none" = everything)')
|
|
41
|
+
.option('--exclude <list>', 'comma-separated path patterns to skip ("none" clears)')
|
|
42
|
+
.option('--robots <on|off>', 'obey robots.txt (off only on a verified domain)')
|
|
43
|
+
.option('--pause', 'pause scheduled scans')
|
|
44
|
+
.option('--resume', 'resume scheduled scans')
|
|
45
|
+
.action(async (opts) => {
|
|
46
|
+
const patch = {};
|
|
47
|
+
if (opts.cadence !== undefined) {
|
|
48
|
+
if (!CADENCES.includes(opts.cadence))
|
|
49
|
+
throw usage(`--cadence must be one of ${CADENCES.join(', ')}`);
|
|
50
|
+
patch.cadence = opts.cadence;
|
|
51
|
+
}
|
|
52
|
+
if (opts.pages !== undefined) {
|
|
53
|
+
if (opts.pages < 1)
|
|
54
|
+
throw usage('--pages must be at least 1');
|
|
55
|
+
patch.page_cap = opts.pages;
|
|
56
|
+
}
|
|
57
|
+
if (opts.states !== undefined) {
|
|
58
|
+
const states = parseCsvList(opts.states) ?? [];
|
|
59
|
+
const bad = states.filter((s) => !STATES.includes(s));
|
|
60
|
+
if (!states.length || bad.length)
|
|
61
|
+
throw usage(`--states must be a comma-separated list of ${STATES.join(', ')}`);
|
|
62
|
+
patch.states = [...new Set(states)];
|
|
63
|
+
}
|
|
64
|
+
const start = listOpt(opts.startUrls);
|
|
65
|
+
if (start !== undefined)
|
|
66
|
+
patch.start_urls = start;
|
|
67
|
+
const include = listOpt(opts.include);
|
|
68
|
+
if (include !== undefined)
|
|
69
|
+
patch.include_patterns = include;
|
|
70
|
+
const exclude = listOpt(opts.exclude);
|
|
71
|
+
if (exclude !== undefined)
|
|
72
|
+
patch.exclude_patterns = exclude;
|
|
73
|
+
const robots = parseOnOff(opts.robots, '--robots');
|
|
74
|
+
if (robots !== undefined)
|
|
75
|
+
patch.respect_robots = robots;
|
|
76
|
+
if (opts.pause && opts.resume)
|
|
77
|
+
throw usage('Pass --pause or --resume, not both.');
|
|
78
|
+
if (opts.pause)
|
|
79
|
+
patch.paused = true;
|
|
80
|
+
if (opts.resume)
|
|
81
|
+
patch.paused = false;
|
|
82
|
+
if (!Object.keys(patch).length)
|
|
83
|
+
throw usage('Nothing to change: pass --cadence, --pages, --states, --start-urls, --include, --exclude, --robots, --pause or --resume.');
|
|
84
|
+
const s = await api().updateScanSchedule(siteIdOf(opts.site), patch);
|
|
85
|
+
const clamped = [];
|
|
86
|
+
if (patch.cadence && s.cadence !== patch.cadence)
|
|
87
|
+
clamped.push(`cadence ${patch.cadence} → ${s.cadence} (plan limit)`);
|
|
88
|
+
if (patch.page_cap && s.page_cap !== patch.page_cap)
|
|
89
|
+
clamped.push(`page cap ${patch.page_cap} → ${s.page_cap} (plan limit)`);
|
|
90
|
+
finish({ schedule: s, patch, clamped }, () => {
|
|
91
|
+
human.success('Schedule saved');
|
|
92
|
+
for (const c of clamped)
|
|
93
|
+
human.warn(c);
|
|
94
|
+
human.message(describe(s));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { CliError, usage } from "../errors.js";
|
|
2
|
+
import { askConfirm, ctx, human, bold, dim, yellow } from "../output.js";
|
|
3
|
+
import { parseCsvList, table } from "../util.js";
|
|
4
|
+
import { api, finish, parseOnOff, siteIdOf } from "./_shared.js";
|
|
5
|
+
const STATUSES = ['discovered', 'managed', 'ignored'];
|
|
6
|
+
const BASES = ['consent', 'legitimate_interest', 'contract', 'legal_obligation'];
|
|
7
|
+
/** Options → the partial service the API accepts; only what was passed ends up in the body. */
|
|
8
|
+
function patchFrom(opts) {
|
|
9
|
+
const p = {};
|
|
10
|
+
if (opts.category !== undefined)
|
|
11
|
+
p.category_key = opts.category.trim();
|
|
12
|
+
if (opts.provider !== undefined)
|
|
13
|
+
p.provider_name = opts.provider;
|
|
14
|
+
if (opts.domain !== undefined)
|
|
15
|
+
p.provider_domain = opts.domain.trim().toLowerCase();
|
|
16
|
+
if (opts.hosts !== undefined)
|
|
17
|
+
p.host_patterns = parseCsvList(opts.hosts) ?? [];
|
|
18
|
+
if (opts.scripts !== undefined)
|
|
19
|
+
p.script_patterns = parseCsvList(opts.scripts) ?? [];
|
|
20
|
+
if (opts.cookies !== undefined)
|
|
21
|
+
p.cookie_patterns = parseCsvList(opts.cookies) ?? [];
|
|
22
|
+
if (opts.storage !== undefined)
|
|
23
|
+
p.storage_keys = parseCsvList(opts.storage) ?? [];
|
|
24
|
+
if (opts.iframes !== undefined)
|
|
25
|
+
p.iframe_patterns = parseCsvList(opts.iframes) ?? [];
|
|
26
|
+
if (opts.basis !== undefined) {
|
|
27
|
+
if (!BASES.includes(opts.basis))
|
|
28
|
+
throw usage(`--basis must be one of ${BASES.join(', ')}`);
|
|
29
|
+
p.legal_basis = opts.basis;
|
|
30
|
+
}
|
|
31
|
+
if (opts.justification !== undefined)
|
|
32
|
+
p.justification = opts.justification || null;
|
|
33
|
+
if (opts.privacyUrl !== undefined)
|
|
34
|
+
p.privacy_url = opts.privacyUrl || null;
|
|
35
|
+
const fp = parseOnOff(opts.firstParty, '--first-party');
|
|
36
|
+
if (fp !== undefined)
|
|
37
|
+
p.first_party = fp;
|
|
38
|
+
if (opts.status !== undefined) {
|
|
39
|
+
if (!STATUSES.includes(opts.status))
|
|
40
|
+
throw usage(`--status must be one of ${STATUSES.join(', ')}`);
|
|
41
|
+
p.status = opts.status;
|
|
42
|
+
}
|
|
43
|
+
if (opts.ignoreReason !== undefined)
|
|
44
|
+
p.ignore_reason = opts.ignoreReason || null;
|
|
45
|
+
if (opts.description !== undefined)
|
|
46
|
+
p.descriptions = { en: opts.description };
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
function addOptions(cmd) {
|
|
50
|
+
return cmd
|
|
51
|
+
.option('--provider <name>', 'provider name, e.g. "Google"')
|
|
52
|
+
.option('--domain <host>', 'provider domain, e.g. google.com')
|
|
53
|
+
.option('--hosts <list>', 'comma-separated host patterns the service loads from')
|
|
54
|
+
.option('--scripts <list>', 'comma-separated script URL patterns')
|
|
55
|
+
.option('--cookies <list>', 'comma-separated cookie name patterns')
|
|
56
|
+
.option('--storage <list>', 'comma-separated localStorage / sessionStorage keys')
|
|
57
|
+
.option('--iframes <list>', 'comma-separated iframe URL patterns')
|
|
58
|
+
.option('--basis <basis>', `legal basis: ${BASES.join(', ')}`)
|
|
59
|
+
.option('--justification <text>', 'why this basis applies (legitimate interest needs one)')
|
|
60
|
+
.option('--privacy-url <url>', "the provider's privacy policy")
|
|
61
|
+
.option('--first-party <on|off>', 'the service is first-party')
|
|
62
|
+
.option('--status <status>', `${STATUSES.join(', ')}`)
|
|
63
|
+
.option('--ignore-reason <text>', 'why an ignored service is ignored')
|
|
64
|
+
.option('--description <text>', 'what the visitor is told (English; other languages in the dashboard)');
|
|
65
|
+
}
|
|
66
|
+
function describe(s) {
|
|
67
|
+
return [
|
|
68
|
+
`${bold('Service')} ${s.name} ${dim(s.id)}`,
|
|
69
|
+
`${bold('Category')} ${s.category_key} ${dim(String(s.status ?? ''))}`,
|
|
70
|
+
`${bold('Provider')} ${s.provider_name ?? dim('—')} ${s.provider_domain ? dim(String(s.provider_domain)) : ''}`,
|
|
71
|
+
`${bold('Basis')} ${s.legal_basis ?? dim('—')}${s.justification ? ` ${dim(String(s.justification))}` : ''}`,
|
|
72
|
+
`${bold('Matches')} hosts ${(s.host_patterns ?? []).join(', ') || dim('—')}; scripts ${(s.script_patterns ?? []).join(', ') || dim('—')}; cookies ${(s.cookie_patterns ?? []).join(', ') || dim('—')}`,
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
export function register(program) {
|
|
76
|
+
const services = program.command('services').description('The trackers a site declares: list, add, update, remove');
|
|
77
|
+
services
|
|
78
|
+
.command('list', { isDefault: true })
|
|
79
|
+
.description('List the services of a site')
|
|
80
|
+
.option('--site <id>', 'site id (default: the linked site)')
|
|
81
|
+
.option('--status <status>', `only ${STATUSES.join(', ')}`)
|
|
82
|
+
.action(async (opts) => {
|
|
83
|
+
if (opts.status !== undefined && !STATUSES.includes(opts.status))
|
|
84
|
+
throw usage(`--status must be one of ${STATUSES.join(', ')}`);
|
|
85
|
+
const items = await api().services(siteIdOf(opts.site), opts.status);
|
|
86
|
+
finish({ services: items }, () => {
|
|
87
|
+
if (!items.length)
|
|
88
|
+
return human.print(dim('No services.'));
|
|
89
|
+
human.print(table(items.map((s) => [s.id, s.name, s.category_key, String(s.status ?? ''), String(s.legal_basis ?? ''), String(s.provider_domain ?? ''), (s.host_patterns ?? []).slice(0, 2).join(',')]), ['id', 'name', 'category', 'status', 'basis', 'provider', 'hosts']));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
addOptions(services
|
|
93
|
+
.command('add <name>')
|
|
94
|
+
.description('Declare a service (status managed unless --status says otherwise)')
|
|
95
|
+
.option('--site <id>', 'site id (default: the linked site)')
|
|
96
|
+
.requiredOption('--category <key>', "one of the site's category keys, e.g. analytics")).action(async (name, opts) => {
|
|
97
|
+
const body = { ...patchFrom(opts), name: name.trim(), category_key: String(opts.category).trim() };
|
|
98
|
+
if (!body.name)
|
|
99
|
+
throw usage('The service needs a name.');
|
|
100
|
+
const service = await api().createService(siteIdOf(opts.site), body);
|
|
101
|
+
finish({ service }, () => {
|
|
102
|
+
human.success(`Added ${bold(service.name)} (${service.id})`);
|
|
103
|
+
human.message(describe(service));
|
|
104
|
+
human.outro(`It is part of the declaration and the block list from the next publish (${dim('cookiecrumbs push')}).`);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
addOptions(services
|
|
108
|
+
.command('update <id>')
|
|
109
|
+
.description('Change a service: category, matching patterns, legal basis, status')
|
|
110
|
+
.option('--name <name>', 'new name')
|
|
111
|
+
.option('--category <key>', 'move it to another category')).action(async (id, opts) => {
|
|
112
|
+
const body = patchFrom(opts);
|
|
113
|
+
if (opts.name !== undefined) {
|
|
114
|
+
if (!opts.name.trim())
|
|
115
|
+
throw usage('--name must not be empty');
|
|
116
|
+
body.name = opts.name.trim();
|
|
117
|
+
}
|
|
118
|
+
if (!Object.keys(body).length)
|
|
119
|
+
throw usage('Nothing to change: pass at least one option.');
|
|
120
|
+
const service = await api().updateService(id, body);
|
|
121
|
+
finish({ service, patch: body }, () => {
|
|
122
|
+
human.success(`Updated ${bold(service.name)}`);
|
|
123
|
+
human.message(describe(service));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
services
|
|
127
|
+
.command('remove <id>')
|
|
128
|
+
.alias('delete')
|
|
129
|
+
.description('Delete a service; its findings return to unclassified on the next scan')
|
|
130
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
131
|
+
.action(async (id, opts) => {
|
|
132
|
+
if (!opts.yes && !ctx().json) {
|
|
133
|
+
const ok = await askConfirm(`Delete service ${id}? Its cookies leave the declaration.`, '--yes', false);
|
|
134
|
+
if (!ok)
|
|
135
|
+
throw new CliError('cancelled', 'Cancelled.', 2);
|
|
136
|
+
}
|
|
137
|
+
await api().deleteService(id);
|
|
138
|
+
finish({ deleted: id }, () => {
|
|
139
|
+
human.success(`Deleted service ${id}`);
|
|
140
|
+
human.print(yellow('Publish a new version so the declaration and the block list follow.'));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { usage } from "../errors.js";
|
|
2
|
+
import { human, bold, dim } from "../output.js";
|
|
3
|
+
import { formatDate, parseCsvList, parseIntOpt, table } from "../util.js";
|
|
4
|
+
import { api, finish, parseOnOff, siteIdOf } from "./_shared.js";
|
|
5
|
+
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
6
|
+
const RETENTION = [12, 24, 36, 48, 60, 72, 84, 96, 108, 120];
|
|
7
|
+
function describe(s) {
|
|
8
|
+
const envs = (s.environments ?? []).map((e) => `${e.name} ${dim(String(e.public_key))}`).join('\n ');
|
|
9
|
+
const settings = (s.settings ?? {});
|
|
10
|
+
return [
|
|
11
|
+
`${bold('Site')} ${s.name} ${dim(s.id)}`,
|
|
12
|
+
`${bold('Domain')} ${s.primary_domain}`,
|
|
13
|
+
`${bold('State')} ${String(s.status ?? '—')}`,
|
|
14
|
+
`${bold('Retention')} ${s.retention_months != null ? `${s.retention_months} months` : '—'}`,
|
|
15
|
+
`${bold('Settings')} ${Object.keys(settings).length ? Object.entries(settings).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ') : dim('defaults')}`,
|
|
16
|
+
`${bold('Envs')} ${envs || dim('—')}`,
|
|
17
|
+
`${bold('Added')} ${formatDate(s.created_at)}`,
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
export function register(program) {
|
|
21
|
+
const sites = program.command('sites').description('Sites of the workspace: list, create, rename, retention and settings');
|
|
22
|
+
sites
|
|
23
|
+
.command('list', { isDefault: true })
|
|
24
|
+
.description('List the sites this token can see')
|
|
25
|
+
.action(async () => {
|
|
26
|
+
const items = await api().sites();
|
|
27
|
+
finish({ sites: items }, () => {
|
|
28
|
+
if (!items.length)
|
|
29
|
+
return human.print(dim('No sites.'));
|
|
30
|
+
human.print(table(items.map((s) => [s.id, s.name, s.primary_domain, String(s.status ?? ''), s.retention_months != null ? `${s.retention_months}m` : '—', (s.environments ?? []).map((e) => e.name).join(',')]), ['id', 'name', 'domain', 'state', 'retention', 'environments']));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
sites
|
|
34
|
+
.command('show [id]')
|
|
35
|
+
.description('One site (default: the linked site)')
|
|
36
|
+
.action(async (id) => {
|
|
37
|
+
const site = await api().site(siteIdOf(id));
|
|
38
|
+
finish({ site }, () => human.message(describe(site)));
|
|
39
|
+
});
|
|
40
|
+
sites
|
|
41
|
+
.command('create')
|
|
42
|
+
.description('Create a site; it gets production and preview environments and a first draft')
|
|
43
|
+
.requiredOption('--name <name>', 'site name')
|
|
44
|
+
.requiredOption('--domain <domain>', 'primary domain, e.g. www.example.com')
|
|
45
|
+
.option('--languages <list>', 'comma-separated language tags (default: en)')
|
|
46
|
+
.action(async (opts) => {
|
|
47
|
+
const domain = opts.domain.trim().toLowerCase();
|
|
48
|
+
if (!DOMAIN_RE.test(domain))
|
|
49
|
+
throw usage(`--domain must be a hostname like www.example.com (got "${opts.domain}")`);
|
|
50
|
+
const site = await api().createSite({ name: opts.name.trim(), primary_domain: domain, languages: parseCsvList(opts.languages) });
|
|
51
|
+
finish({ site }, () => {
|
|
52
|
+
human.success(`Created ${bold(site.name)} (${site.id})`);
|
|
53
|
+
human.message(describe(site));
|
|
54
|
+
human.outro(`Link a folder to it with ${dim(`cookiecrumbs link --site ${site.id}`)}`);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
sites
|
|
58
|
+
.command('update [id]')
|
|
59
|
+
.description('Rename a site, set how long consent records are kept, or change a setting')
|
|
60
|
+
.option('--name <name>', 'new name')
|
|
61
|
+
.option('--retention <months>', `months to keep consent records: ${RETENTION.join(', ')}`, parseIntOpt('--retention'))
|
|
62
|
+
.option('--public-versions-feed <on|off>', 'publish the version history feed')
|
|
63
|
+
.option('--consent-cookie-name <name>', 'name of the consent cookie ("none" resets to the default)')
|
|
64
|
+
.option('--reask-months <n>', 'months before visitors are asked again ("none" resets to the default)')
|
|
65
|
+
.action(async (id, opts) => {
|
|
66
|
+
const patch = {};
|
|
67
|
+
if (opts.name !== undefined) {
|
|
68
|
+
if (!opts.name.trim())
|
|
69
|
+
throw usage('--name must not be empty');
|
|
70
|
+
patch.name = opts.name.trim();
|
|
71
|
+
}
|
|
72
|
+
if (opts.retention !== undefined) {
|
|
73
|
+
if (!RETENTION.includes(opts.retention))
|
|
74
|
+
throw usage(`--retention must be one of ${RETENTION.join(', ')}`);
|
|
75
|
+
patch.retention_months = opts.retention;
|
|
76
|
+
}
|
|
77
|
+
const settings = {};
|
|
78
|
+
const feed = parseOnOff(opts.publicVersionsFeed, '--public-versions-feed');
|
|
79
|
+
if (feed !== undefined)
|
|
80
|
+
settings.public_versions_feed = feed;
|
|
81
|
+
if (opts.consentCookieName !== undefined) {
|
|
82
|
+
const v = opts.consentCookieName.trim();
|
|
83
|
+
if (v.toLowerCase() === 'none')
|
|
84
|
+
settings.consent_cookie_name = null;
|
|
85
|
+
else if (!/^[A-Za-z0-9_-]{1,64}$/.test(v))
|
|
86
|
+
throw usage('--consent-cookie-name must be 1..64 characters of letters, digits, _ or -');
|
|
87
|
+
else
|
|
88
|
+
settings.consent_cookie_name = v;
|
|
89
|
+
}
|
|
90
|
+
if (opts.reaskMonths !== undefined) {
|
|
91
|
+
const v = opts.reaskMonths.trim();
|
|
92
|
+
if (v.toLowerCase() === 'none')
|
|
93
|
+
settings.reask_months = null;
|
|
94
|
+
else {
|
|
95
|
+
const n = Number.parseInt(v, 10);
|
|
96
|
+
if (!Number.isInteger(n) || n < 1 || n > 13)
|
|
97
|
+
throw usage('--reask-months must be an integer between 1 and 13, or none');
|
|
98
|
+
settings.reask_months = n;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(settings).length)
|
|
102
|
+
patch.settings = settings;
|
|
103
|
+
if (!Object.keys(patch).length)
|
|
104
|
+
throw usage('Nothing to change: pass --name, --retention, --public-versions-feed, --consent-cookie-name or --reask-months.');
|
|
105
|
+
const site = await api().updateSite(siteIdOf(id), patch);
|
|
106
|
+
finish({ site, patch }, () => {
|
|
107
|
+
human.success(`Updated ${bold(site.name)}`);
|
|
108
|
+
human.message(describe(site));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
import { detectFramework, FRAMEWORK_LABEL } from "../frameworks.js";
|
|
4
|
+
import { configHash } from "../merge.js";
|
|
5
|
+
import { ctx, human, bold, dim, green, yellow } from "../output.js";
|
|
6
|
+
import { configFile, findProjectRoot, loadLocalConfig, requireState, TEXTS_DIR } from "../project.js";
|
|
7
|
+
import { formatDate } from "../util.js";
|
|
8
|
+
import { api, finish } from "./_shared.js";
|
|
9
|
+
export function register(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('status')
|
|
12
|
+
.description('Local vs remote state of the linked site')
|
|
13
|
+
.action(async () => {
|
|
14
|
+
const root = findProjectRoot(ctx().cwd);
|
|
15
|
+
const state = requireState(root);
|
|
16
|
+
const a = api();
|
|
17
|
+
const file = configFile(root);
|
|
18
|
+
const framework = detectFramework(root);
|
|
19
|
+
let localHash = null;
|
|
20
|
+
let localError = null;
|
|
21
|
+
let langs = [];
|
|
22
|
+
if (file) {
|
|
23
|
+
try {
|
|
24
|
+
const l = await loadLocalConfig(root);
|
|
25
|
+
localHash = configHash(l.config);
|
|
26
|
+
langs = l.config.languages;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
localError = e instanceof CliError ? e.detail : String(e);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const [site, draft, versions] = await Promise.all([
|
|
33
|
+
a.site(state.site_id),
|
|
34
|
+
a.getDraft(state.site_id, state.env).catch((e) => {
|
|
35
|
+
if (e instanceof CliError && e.status === 404)
|
|
36
|
+
return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}),
|
|
39
|
+
a.versions(state.site_id).catch(() => []),
|
|
40
|
+
]);
|
|
41
|
+
const remoteHash = draft ? configHash(draft.config) : null;
|
|
42
|
+
const localModified = localHash !== null && localHash !== state.last_pulled_hash;
|
|
43
|
+
const remoteModified = remoteHash !== null && remoteHash !== state.last_pulled_hash;
|
|
44
|
+
const byNumber = [...versions].sort((x, y) => y.number - x.number);
|
|
45
|
+
const latest = (env) => byNumber.find((v) => v.environment === env) ?? null;
|
|
46
|
+
const latestAny = byNumber[0] ?? null;
|
|
47
|
+
const latestPreview = latest('preview');
|
|
48
|
+
const latestProd = latest('production');
|
|
49
|
+
finish({
|
|
50
|
+
root,
|
|
51
|
+
site: { id: site.id, name: site.name, primary_domain: site.primary_domain },
|
|
52
|
+
env: state.env,
|
|
53
|
+
config_file: file ? relative(root, file.path) : null,
|
|
54
|
+
framework: framework.framework,
|
|
55
|
+
languages: langs,
|
|
56
|
+
local_hash: localHash,
|
|
57
|
+
local_error: localError,
|
|
58
|
+
remote_hash: remoteHash,
|
|
59
|
+
last_pulled_hash: state.last_pulled_hash,
|
|
60
|
+
last_pulled_updated_at: state.last_pulled_updated_at,
|
|
61
|
+
remote_updated_at: draft?.updated_at ?? null,
|
|
62
|
+
local_modified: localModified,
|
|
63
|
+
remote_modified: remoteModified,
|
|
64
|
+
in_sync: !localModified && !remoteModified && localHash === remoteHash,
|
|
65
|
+
latest_version: { any: latestAny?.number ?? null, preview: latestPreview?.number ?? null, production: latestProd?.number ?? null },
|
|
66
|
+
}, () => {
|
|
67
|
+
human.intro('status');
|
|
68
|
+
const sync = localError
|
|
69
|
+
? yellow(`local config invalid: ${localError}`)
|
|
70
|
+
: localModified && remoteModified
|
|
71
|
+
? yellow('both local and remote changed since the last pull (pull will conflict)')
|
|
72
|
+
: localModified
|
|
73
|
+
? yellow('local changes not pushed')
|
|
74
|
+
: remoteModified
|
|
75
|
+
? yellow('remote draft changed — run pull')
|
|
76
|
+
: green('in sync');
|
|
77
|
+
human.message([
|
|
78
|
+
`${bold('Site')} ${site.name} ${dim(site.id)} · ${site.primary_domain}`,
|
|
79
|
+
`${bold('Env')} ${state.env}`,
|
|
80
|
+
`${bold('Config')} ${file ? relative(root, file.path) : dim('none')} ${dim(`+ ${TEXTS_DIR}/ (${langs.join(', ') || '—'})`)}`,
|
|
81
|
+
`${bold('Framework')} ${FRAMEWORK_LABEL[framework.framework]}`,
|
|
82
|
+
`${bold('Sync')} ${sync}`,
|
|
83
|
+
`${bold('Last pull')} ${formatDate(state.last_pulled_updated_at)} ${dim(state.last_pulled_hash?.slice(0, 12) ?? '')}`,
|
|
84
|
+
`${bold('Remote')} ${formatDate(draft?.updated_at)} ${dim(remoteHash?.slice(0, 12) ?? '')}`,
|
|
85
|
+
`${bold('Versions')} latest v${latestAny?.number ?? '—'}${latestPreview || latestProd ? ` (preview v${latestPreview?.number ?? '—'} · production v${latestProd?.number ?? '—'})` : ''}`,
|
|
86
|
+
].join('\n'));
|
|
87
|
+
human.outro('OK');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { BannerConfigSchema } from "../configpkg.js";
|
|
4
|
+
import { CliError, usage } from "../errors.js";
|
|
5
|
+
import { askConfirm, ctx, human, bold, dim, green, red } from "../output.js";
|
|
6
|
+
import { formatDate, parseCsvList, table } from "../util.js";
|
|
7
|
+
import { api, envOf, finish, formatLint, project, siteIdOf } from "./_shared.js";
|
|
8
|
+
/** The config groups apply_template understands (contracts.md, phase 5). */
|
|
9
|
+
const GROUPS = ['layout', 'theme', 'texts', 'regions', 'consent_mode', 'behaviour'];
|
|
10
|
+
function describe(t) {
|
|
11
|
+
const cfg = t.config;
|
|
12
|
+
return [
|
|
13
|
+
`${bold('Template')} ${t.name} ${dim(t.id)}`,
|
|
14
|
+
`${bold('Source')} ${t.org_id ? 'workspace' : 'built-in'}`,
|
|
15
|
+
t.description ? `${bold('About')} ${t.description}` : '',
|
|
16
|
+
cfg ? `${bold('Config')} layout ${cfg.layout} · ${cfg.languages?.length ?? 0} language(s) · ${cfg.categories?.length ?? 0} categories · theme ${cfg.theme?.system ?? 'classic'}` : '',
|
|
17
|
+
`${bold('Updated')} ${formatDate(t.updated_at ?? t.created_at)}`,
|
|
18
|
+
]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join('\n');
|
|
21
|
+
}
|
|
22
|
+
/** `--config <file>`: a JSON BannerConfig, validated locally before it is sent. */
|
|
23
|
+
function readConfigFile(file) {
|
|
24
|
+
const path = resolve(ctx().cwd, file);
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw usage(`Could not read ${file} as JSON: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
const parsed = BannerConfigSchema.safeParse(raw);
|
|
33
|
+
if (!parsed.success)
|
|
34
|
+
throw new CliError('schema', `${file} is not a valid BannerConfig: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`, 1);
|
|
35
|
+
return parsed.data;
|
|
36
|
+
}
|
|
37
|
+
export function register(program) {
|
|
38
|
+
const templates = program.command('templates').description('Banner templates (built-in and workspace)');
|
|
39
|
+
templates
|
|
40
|
+
.command('list', { isDefault: true })
|
|
41
|
+
.description('List the templates this token can apply')
|
|
42
|
+
.action(async () => {
|
|
43
|
+
const items = await api().templates();
|
|
44
|
+
finish({ templates: items }, () => {
|
|
45
|
+
if (!items.length)
|
|
46
|
+
return human.print(dim('No templates.'));
|
|
47
|
+
human.print(table(items.map((t) => [t.id, t.name, t.org_id ? 'workspace' : 'built-in', String(t.description ?? '').slice(0, 60), formatDate(t.updated_at ?? t.created_at)]), ['id', 'name', 'source', 'description', 'updated']));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
templates
|
|
51
|
+
.command('show <id>')
|
|
52
|
+
.description('One template with its configuration')
|
|
53
|
+
.option('--config-only', 'print only the BannerConfig JSON (pipe it into a file)')
|
|
54
|
+
.action(async (id, opts) => {
|
|
55
|
+
const t = await api().template(id);
|
|
56
|
+
finish({ template: t }, () => {
|
|
57
|
+
if (opts.configOnly)
|
|
58
|
+
return human.print(JSON.stringify(t.config ?? null, null, 2));
|
|
59
|
+
human.message(describe(t));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
templates
|
|
63
|
+
.command('save <name>')
|
|
64
|
+
.description("Save a workspace template from a site's draft or from a config file (feature shared_templates)")
|
|
65
|
+
.option('--from-site <id>', 'copy this site’s draft (default: the linked site, unless --config is given)')
|
|
66
|
+
.option('--env <env>', 'which draft to copy: production (default) or preview')
|
|
67
|
+
.option('--config <file>', 'a JSON BannerConfig file to save instead of a draft')
|
|
68
|
+
.option('--id <template>', 'update this workspace template instead of creating a new one')
|
|
69
|
+
.action(async (name, opts) => {
|
|
70
|
+
if (!name.trim())
|
|
71
|
+
throw usage('The template needs a name.');
|
|
72
|
+
const body = { name: name.trim() };
|
|
73
|
+
if (opts.config)
|
|
74
|
+
body.config = readConfigFile(opts.config);
|
|
75
|
+
else {
|
|
76
|
+
body.from_site = siteIdOf(opts.fromSite);
|
|
77
|
+
body.env = envOf(opts.env, 'production');
|
|
78
|
+
}
|
|
79
|
+
const t = opts.id ? await api().updateTemplate(opts.id, body) : await api().createTemplate(body);
|
|
80
|
+
finish({ template: t, updated: Boolean(opts.id) }, () => {
|
|
81
|
+
human.success(`${opts.id ? 'Updated' : 'Saved'} template ${bold(t.name)} (${t.id})`);
|
|
82
|
+
human.message(describe(t));
|
|
83
|
+
human.outro(`Apply it to a site with ${dim(`cookiecrumbs templates apply ${t.id} --site <id>`)}`);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
templates
|
|
87
|
+
.command('delete <id>')
|
|
88
|
+
.description('Delete a workspace template (built-in templates cannot be deleted)')
|
|
89
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
90
|
+
.action(async (id, opts) => {
|
|
91
|
+
if (!opts.yes && !ctx().json) {
|
|
92
|
+
const ok = await askConfirm(`Delete template ${id}?`, '--yes', false);
|
|
93
|
+
if (!ok)
|
|
94
|
+
throw new CliError('cancelled', 'Cancelled.', 2);
|
|
95
|
+
}
|
|
96
|
+
await api().deleteTemplate(id);
|
|
97
|
+
finish({ deleted: id }, () => human.success(`Deleted template ${id}`));
|
|
98
|
+
});
|
|
99
|
+
templates
|
|
100
|
+
.command('apply <template>')
|
|
101
|
+
.description('Apply a template to one or more site drafts (never publishes)')
|
|
102
|
+
.option('--site <ids>', 'comma-separated site ids (default: the linked site)')
|
|
103
|
+
.option('--groups <list>', `config groups to copy: ${GROUPS.join(', ')} (default: all)`)
|
|
104
|
+
.option('--env <env>', 'preview or production draft (default: preview)')
|
|
105
|
+
.action(async (template, opts) => {
|
|
106
|
+
const env = envOf(opts.env, 'preview');
|
|
107
|
+
const groups = parseCsvList(opts.groups);
|
|
108
|
+
const unknown = (groups ?? []).filter((g) => !GROUPS.includes(g));
|
|
109
|
+
if (unknown.length)
|
|
110
|
+
throw usage(`Unknown group(s): ${unknown.join(', ')}. Known groups: ${GROUPS.join(', ')}.`);
|
|
111
|
+
let siteIds = parseCsvList(opts.site);
|
|
112
|
+
if (!siteIds || !siteIds.length)
|
|
113
|
+
siteIds = [project().state.site_id];
|
|
114
|
+
const results = await api().applyTemplate(template, { site_ids: siteIds, groups, env });
|
|
115
|
+
const failed = results.filter((r) => !r.ok);
|
|
116
|
+
const payload = { template, env, groups: groups ?? GROUPS, results, applied: results.length - failed.length, failed: failed.length };
|
|
117
|
+
const render = () => {
|
|
118
|
+
for (const r of results) {
|
|
119
|
+
human.print(`${r.ok ? green('applied') : red('failed ')} ${r.site_id}${r.ok ? '' : ` ${r.error ?? ''}`}`);
|
|
120
|
+
if (r.ok && r.lint && r.lint.issues?.length)
|
|
121
|
+
human.print(formatLint(r.lint.issues));
|
|
122
|
+
}
|
|
123
|
+
human.message(`${bold('Drafts updated')} ${payload.applied}/${results.length} (${env}). ${dim('Review and publish with `cookiecrumbs push` / `versions publish`.')}`);
|
|
124
|
+
};
|
|
125
|
+
// Never report success for a site the server refused: one JSON object either way.
|
|
126
|
+
if (failed.length) {
|
|
127
|
+
if (!ctx().json)
|
|
128
|
+
render();
|
|
129
|
+
throw new CliError('template_apply_failed', `${failed.length} of ${results.length} site(s) could not be updated.`, 1, { extra: payload });
|
|
130
|
+
}
|
|
131
|
+
finish(payload, render);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Api } from "../api.js";
|
|
2
|
+
import { deviceFlow, writeCredentials, DEFAULT_SCOPES } from "../auth.js";
|
|
3
|
+
import { usage } from "../errors.js";
|
|
4
|
+
import { ctx, human, bold, dim } from "../output.js";
|
|
5
|
+
import { cliVersion, formatDate, parseCsvList, table } from "../util.js";
|
|
6
|
+
import { api, finish } from "./_shared.js";
|
|
7
|
+
export function register(program) {
|
|
8
|
+
const tokens = program.command('tokens').description('API tokens of the workspace');
|
|
9
|
+
tokens
|
|
10
|
+
.command('list', { isDefault: true })
|
|
11
|
+
.description('List tokens (no secrets)')
|
|
12
|
+
.option('--all', 'include revoked tokens')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
let rows = await api().tokens();
|
|
15
|
+
if (!opts.all)
|
|
16
|
+
rows = rows.filter((t) => !t.revoked_at);
|
|
17
|
+
finish({ tokens: rows }, () => {
|
|
18
|
+
if (!rows.length)
|
|
19
|
+
return human.print(dim('No tokens.'));
|
|
20
|
+
human.print(table(rows.map((t) => [t.prefix ?? '', t.name, t.kind, (t.scopes ?? []).join(','), t.site_id ? `${t.site_id.slice(0, 8)}…${t.environment ? '/' + t.environment : ''}` : 'any', formatDate(t.expires_at), formatDate(t.last_used_at), t.revoked_at ? 'revoked' : 'active', t.id]), ['prefix', 'name', 'kind', 'scopes', 'site', 'expires', 'last used', 'state', 'id']));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
tokens
|
|
24
|
+
.command('revoke <id>')
|
|
25
|
+
.description('Revoke a token by id')
|
|
26
|
+
.option('--note <text>', 'reason for the audit trail')
|
|
27
|
+
.action(async (id, opts) => {
|
|
28
|
+
const r = await api().revokeToken(id, opts.note);
|
|
29
|
+
finish({ revoked: id, result: r ?? null }, () => human.success(`Revoked token ${id}`));
|
|
30
|
+
});
|
|
31
|
+
tokens
|
|
32
|
+
.command('create')
|
|
33
|
+
.description('Create a token through the device flow (approve in the dashboard; the secret is printed once)')
|
|
34
|
+
.option('--scopes <list>', `scopes to request (default: ${DEFAULT_SCOPES.join(',')})`)
|
|
35
|
+
.option('--use', 'also store it as this machine\'s credentials')
|
|
36
|
+
.option('--no-open', 'do not open the browser; just print the URL')
|
|
37
|
+
.action(async (opts) => {
|
|
38
|
+
if (ctx().json)
|
|
39
|
+
throw usage('tokens create is interactive (device flow). Create tokens in the dashboard under Developers, or run it without --json.');
|
|
40
|
+
human.intro('tokens create');
|
|
41
|
+
const anon = new Api(ctx().api, null, cliVersion());
|
|
42
|
+
const { token } = await deviceFlow(anon, { scopes: parseCsvList(opts.scopes), open: opts.open });
|
|
43
|
+
human.note(`${token.token}\n\nThis is the only time the secret is shown.`, `Token "${token.name}" (${(token.scopes ?? []).join(', ')})`);
|
|
44
|
+
if (opts.use) {
|
|
45
|
+
const file = writeCredentials({ api: anon.base, token: token.token, org: token.org, name: token.name, token_id: token.token_id, scopes: token.scopes, expires_at: token.expires_at });
|
|
46
|
+
human.success(`Stored as credentials in ${file}`);
|
|
47
|
+
}
|
|
48
|
+
human.outro(`Use it with ${bold('COOKIECRUMBS_TOKEN')} in CI or ${bold('cookiecrumbs login --token')}.`);
|
|
49
|
+
});
|
|
50
|
+
}
|