@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,77 @@
1
+ import { CliError } from "../errors.js";
2
+ import { human, bold, dim, green, red, spinner } from "../output.js";
3
+ import { formatDate, pollMs, parseIntOpt, sleep, table } from "../util.js";
4
+ import { api, finish, siteIdOf } from "./_shared.js";
5
+ function describe(c) {
6
+ const r = (c.result ?? {});
7
+ const lines = [
8
+ `${bold('Checked')} ${formatDate(c.checked_at)} ${c.ok ? green('OK') : red('not OK')}`,
9
+ `${bold('Page')} ${c.url}`,
10
+ ];
11
+ for (const [k, v] of Object.entries(r)) {
12
+ if (v === null || v === undefined)
13
+ continue;
14
+ lines.push(`${bold(k.padEnd(8))} ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
15
+ }
16
+ return lines.join('\n');
17
+ }
18
+ export function register(program) {
19
+ const install = program.command('install').description('Is cc.js on the site? Queue an install check and read the results');
20
+ install
21
+ .command('status', { isDefault: true })
22
+ .description('The most recent install checks')
23
+ .option('--site <id>', 'site id (default: the linked site)')
24
+ .option('--limit <n>', 'rows (1..100)', parseIntOpt('--limit'), 5)
25
+ .action(async (opts) => {
26
+ const items = await api().installChecks(siteIdOf(opts.site), opts.limit);
27
+ finish({ checks: items, ok: items[0]?.ok ?? null }, () => {
28
+ if (!items.length)
29
+ return human.print(dim('No install check has run yet. Start one with `cookiecrumbs install check --wait`.'));
30
+ human.print(table(items.map((c) => [c.ok ? green('OK') : red('fail'), formatDate(c.checked_at), c.kind, c.url]), ['result', 'checked', 'kind', 'page']));
31
+ human.message(describe(items[0]));
32
+ });
33
+ });
34
+ install
35
+ .command('check')
36
+ .description('Queue an install check of the site (the worker fetches the page and looks for cc.js)')
37
+ .option('--site <id>', 'site id (default: the linked site)')
38
+ .option('--url <url>', 'page to check (default: the home page)')
39
+ .option('--wait', 'wait for the result and exit 1 if the install is broken')
40
+ .option('--timeout <seconds>', 'give up waiting after this long', parseIntOpt('--timeout'), 120)
41
+ .action(async (opts) => {
42
+ const a = api();
43
+ const site = siteIdOf(opts.site);
44
+ const queued = await a.requestInstallCheck(site, opts.url);
45
+ if (!opts.wait) {
46
+ finish({ ...queued, waited: false }, () => {
47
+ human.success(`Queued an install check of ${queued.url}`);
48
+ human.outro(`Read the result with ${dim('cookiecrumbs install status')} in a moment, or run with --wait.`);
49
+ });
50
+ return;
51
+ }
52
+ const s = spinner(`Checking ${queued.url}`);
53
+ // requested_at and checked_at are both server clocks, so a plain comparison tells this run's
54
+ // result from the one before it.
55
+ const since = Date.parse(queued.requested_at) || Date.now();
56
+ const deadline = Date.now() + opts.timeout * 1000;
57
+ let found = null;
58
+ while (Date.now() < deadline) {
59
+ await sleep(pollMs(3000));
60
+ const items = await a.installChecks(site, 5);
61
+ found = items.find((c) => c.kind === 'install' && Date.parse(c.checked_at) >= since) ?? null;
62
+ if (found)
63
+ break;
64
+ }
65
+ if (!found) {
66
+ s.fail('No result within the timeout');
67
+ throw new CliError('timeout', `No install check result within ${opts.timeout}s. Is a worker running? The check stays queued; read it later with \`cookiecrumbs install status\`.`, 1, { extra: { ...queued, waited: true } });
68
+ }
69
+ s.stop(found.ok ? green('Install OK') : red('Install broken'));
70
+ const json = { ...queued, waited: true, check: found, ok: found.ok };
71
+ if (!found.ok) {
72
+ human.message(describe(found));
73
+ throw new CliError('install_broken', `cc.js was not found working on ${found.url}.`, 1, { extra: json });
74
+ }
75
+ finish(json, () => human.message(describe(found)));
76
+ });
77
+ }
@@ -0,0 +1,61 @@
1
+ import { usage } from "../errors.js";
2
+ import { human, bold, dim, red, yellow } from "../output.js";
3
+ import { formatDate, table } from "../util.js";
4
+ import { api, finish, siteIdOf } from "./_shared.js";
5
+ const STATUSES = ['open', 'resolved', 'suppressed', 'all'];
6
+ const colourSeverity = (s) => (s === 'critical' || s === 'high' ? red(s) : s === 'medium' ? yellow(s) : dim(s));
7
+ function describe(i) {
8
+ const rule = (i.rule ?? {});
9
+ return [
10
+ `${bold('Issue')} ${i.id}`,
11
+ `${bold('Code')} ${i.code} ${colourSeverity(String(i.severity))}`,
12
+ `${bold('Status')} ${i.status}${i.suppress_reason ? ` ${dim(String(i.suppress_reason))}` : ''}`,
13
+ rule.title ? `${bold('Rule')} ${rule.title}` : '',
14
+ rule.summary ? ` ${dim(rule.summary)}` : '',
15
+ `${bold('Seen')} ${formatDate(i.first_seen)} → ${formatDate(i.last_seen)}`,
16
+ ]
17
+ .filter(Boolean)
18
+ .join('\n');
19
+ }
20
+ export function register(program) {
21
+ const issues = program.command('issues').description('Compliance issues of a site, and their suppression');
22
+ issues
23
+ .command('list', { isDefault: true })
24
+ .description('List issues (open by default)')
25
+ .option('--site <id>', 'site id (default: the linked site)')
26
+ .option('--status <status>', `${STATUSES.join(', ')} (default: open)`)
27
+ .action(async (opts) => {
28
+ const status = (opts.status ?? 'open').toLowerCase();
29
+ if (!STATUSES.includes(status))
30
+ throw usage(`--status must be one of ${STATUSES.join(', ')}`);
31
+ const items = await api().issuesOf(siteIdOf(opts.site), status);
32
+ finish({ issues: items, status }, () => {
33
+ if (!items.length)
34
+ return human.print(dim(`No ${status === 'all' ? '' : status + ' '}issues. There is no score; each issue stands on its own rule.`));
35
+ human.print(table(items.map((i) => [i.id, i.code, colourSeverity(String(i.severity)), String(i.status), formatDate(i.last_seen), String(i.evidence?.key ?? '').slice(0, 40)]), ['id', 'code', 'severity', 'status', 'last seen', 'evidence']));
36
+ });
37
+ });
38
+ issues
39
+ .command('suppress <id>')
40
+ .description('Suppress an issue with a reason (the reason is the record)')
41
+ .requiredOption('--reason <text>', 'why this issue does not apply')
42
+ .action(async (id, opts) => {
43
+ if (!opts.reason.trim())
44
+ throw usage('--reason must not be empty');
45
+ const issue = await api().suppressIssue(id, opts.reason.trim());
46
+ finish({ issue }, () => {
47
+ human.success(`Suppressed ${id}`);
48
+ human.message(describe(issue));
49
+ });
50
+ });
51
+ issues
52
+ .command('unsuppress <id>')
53
+ .description('Reopen a suppressed issue')
54
+ .action(async (id) => {
55
+ const issue = await api().unsuppressIssue(id);
56
+ finish({ issue }, () => {
57
+ human.success(`Reopened ${id}`);
58
+ human.message(describe(issue));
59
+ });
60
+ });
61
+ }
@@ -0,0 +1,41 @@
1
+ import { resolve } from 'node:path';
2
+ import { askSelect, ctx, human, bold } from "../output.js";
3
+ import { findProjectRoot, readState, writeState } from "../project.js";
4
+ import { api, envOf, finish } from "./_shared.js";
5
+ export function register(program) {
6
+ program
7
+ .command('link')
8
+ .description('Point this folder at a site/environment without rewriting the config')
9
+ .option('--site <id>', 'site id')
10
+ .option('--env <env>', 'preview or production')
11
+ .action(async (opts) => {
12
+ human.intro('link');
13
+ const root = findProjectRoot(ctx().cwd) || resolve(ctx().cwd);
14
+ const prev = readState(root);
15
+ const a = api();
16
+ const me = await a.me();
17
+ let siteId = opts.site;
18
+ if (!siteId) {
19
+ const sites = await a.sites();
20
+ siteId = await askSelect('Which site?', '--site <id>', sites.map((s) => ({ value: s.id, label: s.name, hint: s.primary_domain })));
21
+ }
22
+ const site = await a.site(siteId);
23
+ const env = envOf(opts.env, prev?.env ?? 'preview');
24
+ const sameSite = prev?.site_id === site.id && prev?.env === env;
25
+ writeState(root, {
26
+ site_id: site.id,
27
+ env,
28
+ last_pulled_hash: sameSite ? prev.last_pulled_hash : null,
29
+ last_pulled_updated_at: sameSite ? prev.last_pulled_updated_at : null,
30
+ site_name: site.name,
31
+ org_slug: me.org.slug,
32
+ public_keys: Object.fromEntries((site.environments ?? []).map((e) => [e.name, e.public_key])),
33
+ });
34
+ finish({ site: { id: site.id, name: site.name }, env, root, reset_sync: !sameSite }, () => {
35
+ human.success(`Linked ${bold(site.name)} (${site.id}) · ${env}`);
36
+ if (!sameSite)
37
+ human.info('Sync state reset: run `cookiecrumbs pull` (or `push`) next.');
38
+ human.outro('Done');
39
+ });
40
+ });
41
+ }
@@ -0,0 +1,98 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { CliError, usage } from "../errors.js";
5
+ import { noteWorker, waitForExport } from "../jobs.js";
6
+ import { ctx, human, bold, dim, green, yellow } from "../output.js";
7
+ import { findProjectRoot, requireState } from "../project.js";
8
+ import { firstOfMonth, parseIntOpt, toIso } from "../util.js";
9
+ import { api, envOf, finish } from "./_shared.js";
10
+ /** Download a completed export job to `file` and verify the X-Content-SHA256 header against the bytes. */
11
+ export async function downloadExport(a, job, file) {
12
+ const d = await a.exportDownload(job.id);
13
+ mkdirSync(dirname(file), { recursive: true });
14
+ writeFileSync(file, d.bytes);
15
+ const sha256 = createHash('sha256').update(d.bytes).digest('hex');
16
+ const header = d.headers['x-content-sha256'] ?? job.sha256 ?? null;
17
+ return {
18
+ job,
19
+ file,
20
+ bytes: d.bytes.length,
21
+ sha256,
22
+ sha256_header: header,
23
+ sha256_verified: header ? header.toLowerCase() === sha256 : null,
24
+ signature: d.headers['x-signature-ed25519'] ?? job.signature ?? null,
25
+ kid: d.headers['x-signing-kid'] ?? null,
26
+ };
27
+ }
28
+ export function describeDownload(r) {
29
+ return [
30
+ `${bold('File')} ${r.file} ${dim(`(${r.bytes} bytes)`)}`,
31
+ `${bold('sha256')} ${r.sha256} ${r.sha256_verified === true ? green('matches X-Content-SHA256') : r.sha256_verified === false ? yellow('DOES NOT match X-Content-SHA256') : dim('(no header to compare)')}`,
32
+ `${bold('Signature')} ${r.signature ? `${r.signature.slice(0, 24)}… ${dim(`Ed25519, kid ${r.kid ?? '?'}`)}` : dim('none')}`,
33
+ ].join('\n');
34
+ }
35
+ /** Create (or re-attach to) an export job and wait for it. */
36
+ export async function runExportJob(a, siteId, body, opts) {
37
+ let job;
38
+ if (opts.jobId) {
39
+ job = await a.exportJob(opts.jobId);
40
+ human.info(`Attached to export job ${job.id} (${job.status})`);
41
+ }
42
+ else {
43
+ try {
44
+ job = await a.createExport(siteId, body);
45
+ }
46
+ catch (e) {
47
+ if (e instanceof CliError && /export_in_progress/i.test(e.code + e.detail)) {
48
+ noteWorker();
49
+ throw new CliError('export_in_progress', `${e.detail} Another export job for this site is still queued or running; wait for it (or re-attach with --job <id>).`, 1, { status: e.status, extra: e.extra });
50
+ }
51
+ throw e;
52
+ }
53
+ human.step(`Export job ${bold(job.id)} ${job.status} (${body.kind})`);
54
+ }
55
+ if (job.status === 'completed')
56
+ return job;
57
+ return waitForExport(a, job.id, { timeoutMs: opts.timeoutMs, label: opts.label });
58
+ }
59
+ export function register(program) {
60
+ const logs = program.command('logs').description('Consent log exports');
61
+ logs
62
+ .command('export')
63
+ .description('Export consent records (signed JSONL or CSV) through an export job')
64
+ .option('--from <date>', 'start (YYYY-MM-DD or ISO; default: first day of this month)')
65
+ .option('--to <date>', 'end (default: now)')
66
+ .option('--env <env>', 'preview or production (default: the linked environment)')
67
+ .option('--format <fmt>', 'jsonl or csv', 'jsonl')
68
+ .option('--out <file>', 'output file (default: cookiecrumbs-consents-<env>-<from>-<to>.<ext>)')
69
+ .option('--job <id>', 're-attach to an existing export job (extension)')
70
+ .option('--timeout <seconds>', 'give up waiting after this many seconds', parseIntOpt('--timeout'), 600)
71
+ .action(async (opts) => {
72
+ human.intro('logs export');
73
+ const root = findProjectRoot(ctx().cwd);
74
+ const state = requireState(root);
75
+ const env = envOf(opts.env, state.env);
76
+ const format = opts.format.toLowerCase();
77
+ if (format !== 'jsonl' && format !== 'csv')
78
+ throw usage('--format must be jsonl or csv');
79
+ const kind = format === 'csv' ? 'consent_csv' : 'consent_jsonl';
80
+ let from;
81
+ let to;
82
+ try {
83
+ from = toIso(opts.from, firstOfMonth);
84
+ to = toIso(opts.to, () => new Date());
85
+ }
86
+ catch (e) {
87
+ throw usage(e.message);
88
+ }
89
+ const a = api();
90
+ const job = await runExportJob(a, state.site_id, { kind, from, to, env }, { jobId: opts.job, timeoutMs: opts.timeout * 1000, label: 'Consent export' });
91
+ const file = resolve(ctx().cwd, opts.out ?? job.filename ?? `cookiecrumbs-consents-${env}-${from.slice(0, 10)}-${to.slice(0, 10)}.${format}`);
92
+ const r = await downloadExport(a, job, file);
93
+ finish({ env, from, to, format, ...r, job: { ...r.job } }, () => {
94
+ human.message(describeDownload(r));
95
+ human.outro(`Verify later: openssl dgst -sha256 ${file}`);
96
+ });
97
+ });
98
+ }
@@ -0,0 +1,45 @@
1
+ import { usage } from "../errors.js";
2
+ import { ctx, human } from "../output.js";
3
+ import { findProjectRoot, requireState, writeState } from "../project.js";
4
+ import { appUrl, openBrowser } from "../util.js";
5
+ import { api, finish } from "./_shared.js";
6
+ const PAGES = {
7
+ overview: '',
8
+ banner: 'banner',
9
+ versions: 'banner/versions',
10
+ services: 'services',
11
+ inbox: 'services/inbox',
12
+ declaration: 'declaration',
13
+ scans: 'scans',
14
+ logs: 'logs',
15
+ exports: 'logs/exports',
16
+ issues: '',
17
+ settings: 'settings',
18
+ developers: '__workspace__/developers',
19
+ };
20
+ export function register(program) {
21
+ program
22
+ .command('open [page]')
23
+ .description(`Open the dashboard page for the linked site (${Object.keys(PAGES).join(', ')})`)
24
+ .option('--no-open', 'print the URL only')
25
+ .action(async (page, opts) => {
26
+ const root = findProjectRoot(ctx().cwd);
27
+ const state = requireState(root);
28
+ const key = (page ?? 'overview').toLowerCase();
29
+ if (!(key in PAGES))
30
+ throw usage(`Unknown page "${page}". One of: ${Object.keys(PAGES).join(', ')}`);
31
+ let slug = state.org_slug;
32
+ if (!slug) {
33
+ slug = (await api().me()).org.slug;
34
+ writeState(root, { ...state, org_slug: slug });
35
+ }
36
+ const path = PAGES[key];
37
+ const url = path.startsWith('__workspace__/') ? `${appUrl()}/w/${slug}/${path.slice('__workspace__/'.length)}` : `${appUrl()}/w/${slug}/s/${state.site_id}${path ? '/' + path : ''}`;
38
+ const opened = opts.open && !ctx().json ? openBrowser(url) : false;
39
+ finish({ url, page: key, opened }, () => {
40
+ human.print(url);
41
+ if (!opened)
42
+ human.print('(copy the URL into your browser)');
43
+ });
44
+ });
45
+ }
@@ -0,0 +1,89 @@
1
+ import { relative } from 'node:path';
2
+ import { diffConfigs, formatDiff, summariseDiff } from "../diff.js";
3
+ import { CliError } from "../errors.js";
4
+ import { configHash, mergeDecision } from "../merge.js";
5
+ import { ctx, human, bold, dim } from "../output.js";
6
+ import { CONFIG_REMOTE, clearRemoteConflict, configFile, findProjectRoot, loadLocalConfig, requireState, writeLocalConfig, writePulled, writeRemoteConflict, writeState } from "../project.js";
7
+ import { api, envOf, finish } from "./_shared.js";
8
+ export function register(program) {
9
+ program
10
+ .command('pull')
11
+ .description('Fetch the remote draft (three-way: overwrite, keep or conflict)')
12
+ .option('--env <env>', 'preview or production (default: the linked environment)')
13
+ .option('--force', 'overwrite local changes with the remote draft')
14
+ .action(async (opts) => {
15
+ human.intro('pull');
16
+ const root = findProjectRoot(ctx().cwd);
17
+ const state = requireState(root);
18
+ const env = envOf(opts.env, state.env);
19
+ const a = api();
20
+ const remote = await a.getDraft(state.site_id, env);
21
+ const existing = configFile(root);
22
+ let local = null;
23
+ if (existing && !opts.force) {
24
+ local = (await loadLocalConfig(root)).config;
25
+ }
26
+ else if (existing) {
27
+ try {
28
+ local = (await loadLocalConfig(root)).config;
29
+ }
30
+ catch {
31
+ local = null; // --force: a broken local file is simply replaced
32
+ }
33
+ }
34
+ const base = env === state.env ? state.last_pulled_hash : null;
35
+ const remoteHash = configHash(remote.config);
36
+ let decision = local ? mergeDecision(base, local, remote.config).decision : 'overwrite';
37
+ const localHash = local ? configHash(local) : null;
38
+ const diff = local ? diffConfigs(local, remote.config) : diffConfigs({}, remote.config);
39
+ if (decision === 'conflict' && opts.force)
40
+ decision = 'overwrite';
41
+ const write = () => {
42
+ const files = writeLocalConfig(root, remote.config, { format: existing?.format ?? 'ts' });
43
+ writePulled(root, remote.config);
44
+ writeState(root, { ...state, env, last_pulled_hash: remoteHash, last_pulled_updated_at: remote.updated_at ?? null });
45
+ clearRemoteConflict(root);
46
+ return files.map((f) => relative(root, f));
47
+ };
48
+ if (decision === 'overwrite') {
49
+ const files = write();
50
+ finish({ env, decision, changed: diff.length > 0, diff, remote_hash: remoteHash, local_hash: localHash, base_hash: base, updated_at: remote.updated_at, files }, () => {
51
+ if (diff.length) {
52
+ human.print(formatDiff(diff));
53
+ human.success(`Pulled the ${env} draft (${summariseDiff(diff)}) → ${files.join(', ')}`);
54
+ }
55
+ else
56
+ human.success(`Pulled the ${env} draft — files rewritten, no content changes.`);
57
+ human.outro(dim(`Draft updated ${remote.updated_at ?? '—'}`));
58
+ });
59
+ return;
60
+ }
61
+ if (decision === 'keep') {
62
+ const remoteUnchanged = remoteHash === base || remoteHash === localHash;
63
+ // Keep the If-Match timestamp fresh when nothing new is upstream.
64
+ if (remoteUnchanged && remote.updated_at && remote.updated_at !== state.last_pulled_updated_at && env === state.env) {
65
+ writeState(root, { ...state, last_pulled_updated_at: remote.updated_at });
66
+ }
67
+ const localChanged = localHash !== base && localHash !== remoteHash;
68
+ finish({ env, decision, changed: false, local_changed: localChanged, diff, remote_hash: remoteHash, local_hash: localHash, base_hash: base, updated_at: remote.updated_at }, () => {
69
+ if (!localChanged)
70
+ human.success('No changes — local config matches the remote draft.');
71
+ else
72
+ human.success(`Remote draft unchanged; kept your local changes (${summariseDiff(diff)} vs remote). Run ${bold('cookiecrumbs push')} when ready.`);
73
+ human.outro('Done');
74
+ });
75
+ return;
76
+ }
77
+ // conflict
78
+ const file = writeRemoteConflict(root, remote.config);
79
+ if (diff.length)
80
+ human.print(formatDiff(diff));
81
+ if (ctx().json) {
82
+ throw new CliError('conflict', `Both the local config and the ${env} draft changed since the last pull.`, 2, {
83
+ extra: { decision, env, diff, remote_file: CONFIG_REMOTE, remote_hash: remoteHash, local_hash: localHash, base_hash: base, updated_at: remote.updated_at },
84
+ });
85
+ }
86
+ throw new CliError('conflict', `Both the local config and the ${env} draft changed since the last pull (${summariseDiff(diff)}).\n` +
87
+ `The remote draft was written to ${relative(root, file)}. Merge by hand, then \`cookiecrumbs push\`; or \`cookiecrumbs pull --force\` to take the remote version.`, 2);
88
+ });
89
+ }
@@ -0,0 +1,110 @@
1
+ import { lintConfig } from "../configpkg.js";
2
+ import { diffConfigs, formatDiff, summariseDiff } from "../diff.js";
3
+ import { CliError } from "../errors.js";
4
+ import { configHash } from "../merge.js";
5
+ import { ctx, human, bold, dim, spinner } from "../output.js";
6
+ import { findProjectRoot, loadLocalConfig, readPulled, requireState, writePulled, writeState } from "../project.js";
7
+ import { api, envOf, finish, lintError, lintFromError, printLint } from "./_shared.js";
8
+ /** Validate locally → PUT draft (If-Match) → POST versions. Shared by `push` and `init --publish`. */
9
+ export async function pushProject(root, opts) {
10
+ const state = requireState(root);
11
+ const env = opts.env ?? state.env;
12
+ const a = api();
13
+ const local = await loadLocalConfig(root);
14
+ const lint = lintConfig(local.config);
15
+ printLint(lint, 'Local lint');
16
+ if (!lint.ok)
17
+ throw lintError(lint);
18
+ const base = readPulled(root);
19
+ let remote = null;
20
+ try {
21
+ remote = await a.getDraft(state.site_id, env);
22
+ }
23
+ catch (e) {
24
+ if (!(e instanceof CliError && e.status === 404))
25
+ throw e;
26
+ }
27
+ const diff = diffConfigs(base ?? remote?.config ?? {}, local.config);
28
+ if (diff.length)
29
+ human.print(formatDiff(diff));
30
+ human.info(`${summariseDiff(diff)} vs ${base ? 'the last pulled config' : 'the remote draft'}`);
31
+ if (opts.dryRun)
32
+ return { env, version: null, diff, lint, dry_run: true, draft_updated_at: remote?.updated_at ?? null };
33
+ const sameEnv = env === state.env;
34
+ const ifMatch = sameEnv ? state.last_pulled_updated_at : null;
35
+ const s = spinner(`Saving draft to ${env}`);
36
+ let saved;
37
+ try {
38
+ saved = await a.putDraft(state.site_id, env, local.config, ifMatch);
39
+ }
40
+ catch (e) {
41
+ if (e instanceof CliError && (e.status === 409 || e.status === 412)) {
42
+ s.fail('The remote draft changed since your last pull');
43
+ throw new CliError('draft_conflict', `The ${env} draft changed since your last pull. Run \`cookiecrumbs pull\` first (it merges or writes cookiecrumbs.config.remote.json).`, 2, { status: e.status, extra: e.extra });
44
+ }
45
+ s.fail('Saving the draft failed');
46
+ throw e;
47
+ }
48
+ s.stop(`Draft saved to ${env}`);
49
+ printLint(saved.lint, 'Server lint');
50
+ if (saved.lint && saved.lint.ok === false)
51
+ throw lintError(saved.lint);
52
+ const publishSpinner = spinner(`Publishing to ${env}`);
53
+ let version;
54
+ try {
55
+ version = await a.publish(state.site_id, { env, note: opts.note, material: Boolean(opts.material) });
56
+ }
57
+ catch (e) {
58
+ publishSpinner.fail('Publish failed');
59
+ if (e instanceof CliError) {
60
+ const l = lintFromError(e);
61
+ if (l)
62
+ printLint(l, 'Lint (server)');
63
+ if (e.status === 402)
64
+ throw new CliError(e.code, `${e.detail} — upgrade the plan or change the config.`, 1, { status: 402, extra: e.extra });
65
+ }
66
+ throw e;
67
+ }
68
+ publishSpinner.stop(`Published version ${bold(String(version.number))} to ${env}`);
69
+ // Refresh the sync state: the server draft now equals the local config.
70
+ let updatedAt = saved.updated_at ?? null;
71
+ try {
72
+ updatedAt = (await a.getDraft(state.site_id, env)).updated_at ?? updatedAt;
73
+ }
74
+ catch {
75
+ /* keep the PUT's timestamp */
76
+ }
77
+ if (sameEnv) {
78
+ writeState(root, { ...state, env, last_pulled_hash: configHash(local.config), last_pulled_updated_at: updatedAt });
79
+ writePulled(root, local.config);
80
+ }
81
+ return { env, version, diff, lint, dry_run: false, draft_updated_at: updatedAt };
82
+ }
83
+ export function register(program) {
84
+ program
85
+ .command('push')
86
+ .description('Validate, save the draft and publish a new version')
87
+ .option('--env <env>', 'preview or production (default: the linked environment)')
88
+ .option('--note <text>', 'version note')
89
+ .option('--material', 'mark the change as material (re-asks consent)')
90
+ .option('--dry-run', 'lint and show the diff without writing anything')
91
+ .action(async (opts) => {
92
+ human.intro('push');
93
+ const root = findProjectRoot(ctx().cwd);
94
+ const state = requireState(root);
95
+ const env = envOf(opts.env, state.env);
96
+ const r = await pushProject(root, { env, note: opts.note, material: opts.material, dryRun: opts.dryRun });
97
+ finish({
98
+ env: r.env,
99
+ dry_run: r.dry_run,
100
+ version: r.version ? { id: r.version.id, number: r.version.number, environment: r.version.environment, note: r.version.note, created_at: r.version.created_at ?? r.version.published_at ?? null } : null,
101
+ diff: r.diff,
102
+ lint: r.lint,
103
+ }, () => {
104
+ if (r.dry_run)
105
+ human.outro(dim('Dry run — nothing was sent.'));
106
+ else
107
+ human.outro(`Version ${bold(String(r.version.number))} is live on ${r.env}.`);
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,94 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { CliError, usage } from "../errors.js";
4
+ import { waitForScan } from "../jobs.js";
5
+ import { ctx, human, bold, dim, red, green, spinner } from "../output.js";
6
+ import { findProjectRoot, requireState } from "../project.js";
7
+ import { parseCsvList, parseIntOpt } from "../util.js";
8
+ import { api, envOf, finish } from "./_shared.js";
9
+ const num = (v) => (typeof v === 'number' ? v : Number(v ?? 0) || 0);
10
+ export function register(program) {
11
+ program
12
+ .command('scan')
13
+ .description('Run a hosted scan of the linked site (use --wait in CI)')
14
+ .option('--env <env>', 'environment to scan (default: the linked environment)')
15
+ .option('--states <list>', 'consent states, comma separated (no_interaction,reject_all,accept_all)')
16
+ .option('--pages <n>', 'page cap', parseIntOpt('--pages'))
17
+ .option('--wait', 'wait for the scan to finish and print the summary')
18
+ .option('--timeout <minutes>', 'give up waiting after this many minutes', parseIntOpt('--timeout'), 30)
19
+ .option('--fail-on-unknown', 'exit 1 when the scan found unclassified trackers')
20
+ .option('--fail-on-preconsent', 'exit 1 when trackers loaded before consent')
21
+ .option('--sarif <file>', 'write the findings as SARIF 2.1.0 to this file (implies --wait)')
22
+ .option('--scan-id <id>', 're-attach to an existing scan instead of starting one (extension)')
23
+ .option('--local', 'local scan (not yet available)')
24
+ .action(async (opts) => {
25
+ if (opts.local)
26
+ throw usage('Local scanning is not yet available — run the hosted scan (`cookiecrumbs scan --wait`).', 'not_available');
27
+ human.intro('scan');
28
+ const root = findProjectRoot(ctx().cwd);
29
+ const state = requireState(root);
30
+ const a = api();
31
+ const env = envOf(opts.env, state.env);
32
+ const wait = Boolean(opts.wait || opts.sarif || opts.failOnUnknown || opts.failOnPreconsent);
33
+ let scan;
34
+ if (opts.scanId) {
35
+ scan = await a.scan(opts.scanId);
36
+ human.info(`Attached to scan ${scan.id} (${scan.status})`);
37
+ }
38
+ else {
39
+ const s = spinner(`Requesting a scan of ${env}`);
40
+ scan = await a.createScan(state.site_id, { env, states: parseCsvList(opts.states), page_cap: opts.pages });
41
+ s.stop(`Scan ${bold(scan.id)} ${scan.status}`);
42
+ }
43
+ if (!wait) {
44
+ finish({ scan_id: scan.id, status: scan.status, env: scan.environment ?? env, waited: false }, () => {
45
+ human.print(scan.id);
46
+ human.outro(`Follow it with ${dim(`cookiecrumbs scan --scan-id ${scan.id} --wait`)} or in the dashboard.`);
47
+ });
48
+ return;
49
+ }
50
+ const done = await waitForScan(a, scan.id, { timeoutMs: opts.timeout * 60 * 1000 });
51
+ const summary = (done.summary ?? {});
52
+ const unclassified = num(summary.unclassified);
53
+ const preconsent = num(summary.preconsent_violations);
54
+ let sarifFile = null;
55
+ if (opts.sarif && done.status === 'completed') {
56
+ const text = await a.findings(done.id, 'sarif');
57
+ sarifFile = resolve(ctx().cwd, opts.sarif);
58
+ mkdirSync(dirname(sarifFile), { recursive: true });
59
+ writeFileSync(sarifFile, text.endsWith('\n') ? text : text + '\n');
60
+ human.step(`Wrote SARIF to ${sarifFile}`);
61
+ }
62
+ const failedOn = [];
63
+ if (done.status !== 'completed')
64
+ failedOn.push(done.status);
65
+ if (opts.failOnUnknown && unclassified > 0)
66
+ failedOn.push('unknown');
67
+ if (opts.failOnPreconsent && preconsent > 0)
68
+ failedOn.push('preconsent');
69
+ const json = { scan_id: done.id, status: done.status, env: done.environment ?? env, waited: true, pages_crawled: done.pages_crawled ?? null, summary, sarif_file: sarifFile, failed_on: failedOn, error: done.error ?? null };
70
+ const lines = [
71
+ `${bold('Status')} ${done.status === 'completed' ? green(done.status) : red(done.status)}${done.error ? ` — ${done.error}` : ''}`,
72
+ `${bold('Pages')} ${done.pages_crawled ?? summary.pages ?? '—'}`,
73
+ `${bold('Findings')} ${summary.findings ?? '—'}`,
74
+ `${bold('Pre-consent')} ${preconsent}${preconsent && opts.failOnPreconsent ? red(' ← fails --fail-on-preconsent') : ''}`,
75
+ `${bold('Unknown')} ${unclassified}${unclassified && opts.failOnUnknown ? red(' ← fails --fail-on-unknown') : ''}`,
76
+ summary.new != null ? `${bold('New')} ${summary.new}` : '',
77
+ summary.removed != null ? `${bold('Removed')} ${summary.removed}` : '',
78
+ summary.blocked_by_bot_protection ? red('Bot protection blocked the crawler; results are partial.') : '',
79
+ ].filter(Boolean);
80
+ if (failedOn.length) {
81
+ human.message(lines.join('\n'));
82
+ const reason = failedOn.includes('unknown')
83
+ ? `${unclassified} unclassified tracker${unclassified === 1 ? '' : 's'}`
84
+ : failedOn.includes('preconsent')
85
+ ? `${preconsent} tracker${preconsent === 1 ? '' : 's'} loaded before consent`
86
+ : `scan ${done.status}`;
87
+ throw new CliError('scan_failed', `Scan ${done.id}: ${reason}.`, 1, { extra: json });
88
+ }
89
+ finish(json, () => {
90
+ human.message(lines.join('\n'));
91
+ human.outro(`Scan ${done.id} passed.`);
92
+ });
93
+ });
94
+ }