@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.
Files changed (62) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +186 -0
  3. package/dist/cli/src/api.js +192 -0
  4. package/dist/cli/src/auth.js +106 -0
  5. package/dist/cli/src/commands/_shared.js +78 -0
  6. package/dist/cli/src/commands/alerts.js +85 -0
  7. package/dist/cli/src/commands/auth.js +92 -0
  8. package/dist/cli/src/commands/declaration.js +45 -0
  9. package/dist/cli/src/commands/diff.js +26 -0
  10. package/dist/cli/src/commands/domains.js +44 -0
  11. package/dist/cli/src/commands/export.js +136 -0
  12. package/dist/cli/src/commands/init.js +134 -0
  13. package/dist/cli/src/commands/install.js +77 -0
  14. package/dist/cli/src/commands/issues.js +61 -0
  15. package/dist/cli/src/commands/link.js +41 -0
  16. package/dist/cli/src/commands/logs.js +98 -0
  17. package/dist/cli/src/commands/open.js +45 -0
  18. package/dist/cli/src/commands/pull.js +89 -0
  19. package/dist/cli/src/commands/push.js +110 -0
  20. package/dist/cli/src/commands/scan.js +94 -0
  21. package/dist/cli/src/commands/schedule.js +97 -0
  22. package/dist/cli/src/commands/services.js +143 -0
  23. package/dist/cli/src/commands/sites.js +111 -0
  24. package/dist/cli/src/commands/status.js +90 -0
  25. package/dist/cli/src/commands/templates.js +133 -0
  26. package/dist/cli/src/commands/tokens.js +50 -0
  27. package/dist/cli/src/commands/usage.js +41 -0
  28. package/dist/cli/src/commands/versions.js +95 -0
  29. package/dist/cli/src/commands/webhooks.js +164 -0
  30. package/dist/cli/src/configpkg.js +10 -0
  31. package/dist/cli/src/diff.js +63 -0
  32. package/dist/cli/src/errors.js +20 -0
  33. package/dist/cli/src/frameworks.js +141 -0
  34. package/dist/cli/src/index.js +100 -0
  35. package/dist/cli/src/jobs.js +59 -0
  36. package/dist/cli/src/merge.js +38 -0
  37. package/dist/cli/src/output.js +112 -0
  38. package/dist/cli/src/project.js +269 -0
  39. package/dist/cli/src/util.js +122 -0
  40. package/dist/config/rules_reference.json +569 -0
  41. package/dist/config/src/canon.js +36 -0
  42. package/dist/config/src/declaration.js +38 -0
  43. package/dist/config/src/defaults.js +804 -0
  44. package/dist/config/src/export.js +130 -0
  45. package/dist/config/src/index.js +16 -0
  46. package/dist/config/src/lint.js +139 -0
  47. package/dist/config/src/regimes.js +62 -0
  48. package/dist/config/src/rules.js +90 -0
  49. package/dist/config/src/schema.js +323 -0
  50. package/dist/config/src/theme.js +147 -0
  51. package/dist/config/src/verify.js +51 -0
  52. package/dist/config/src/webhooks.js +309 -0
  53. package/dist/mcp/src/auth.js +40 -0
  54. package/dist/mcp/src/client.js +44 -0
  55. package/dist/mcp/src/diff.js +134 -0
  56. package/dist/mcp/src/index.js +25 -0
  57. package/dist/mcp/src/matrix.js +106 -0
  58. package/dist/mcp/src/server.js +171 -0
  59. package/dist/mcp/src/shared.js +147 -0
  60. package/dist/mcp/src/tools-config.js +943 -0
  61. package/dist/mcp/src/tools.js +650 -0
  62. 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
+ }