@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,78 @@
1
+ import { Api } from "../api.js";
2
+ import { resolveToken } from "../auth.js";
3
+ import { CliError, usage } from "../errors.js";
4
+ import { ctx, human, printJson, dim, yellow, red } from "../output.js";
5
+ import { findProjectRoot, requireState } from "../project.js";
6
+ import { cliVersion } from "../util.js";
7
+ export function api() {
8
+ const { token } = resolveToken();
9
+ return new Api(ctx().api, token, cliVersion());
10
+ }
11
+ export function apiWithSource() {
12
+ const { token, source } = resolveToken();
13
+ return { api: new Api(ctx().api, token, cliVersion()), source };
14
+ }
15
+ export function project() {
16
+ const root = findProjectRoot(ctx().cwd);
17
+ return { root, state: requireState(root) };
18
+ }
19
+ /** The site a command works on: `--site <id>` when given, else the linked project's site. */
20
+ export function siteIdOf(given) {
21
+ if (given && given.trim())
22
+ return given.trim();
23
+ const root = findProjectRoot(ctx().cwd);
24
+ const state = requireState(root);
25
+ return state.site_id;
26
+ }
27
+ /** `on` / `off` / `true` / `false` / `yes` / `no` → boolean; anything else is a usage error. */
28
+ export function parseOnOff(value, flag) {
29
+ if (value === undefined)
30
+ return undefined;
31
+ const v = value.trim().toLowerCase();
32
+ if (['on', 'true', 'yes', '1'].includes(v))
33
+ return true;
34
+ if (['off', 'false', 'no', '0'].includes(v))
35
+ return false;
36
+ throw usage(`${flag} must be on or off (got "${value}")`);
37
+ }
38
+ export function envOf(value, fallback = 'preview') {
39
+ const v = (value ?? fallback).toLowerCase();
40
+ if (v === 'prod')
41
+ return 'production';
42
+ if (v !== 'production' && v !== 'preview')
43
+ throw usage(`--env must be production or preview (got "${value}")`);
44
+ return v;
45
+ }
46
+ /** Print either the single JSON object or the human rendering. */
47
+ export function finish(json, humanOut) {
48
+ if (ctx().json)
49
+ printJson({ ok: true, ...json });
50
+ else
51
+ humanOut();
52
+ }
53
+ export function formatLint(issues) {
54
+ if (!issues.length)
55
+ return dim('no lint issues');
56
+ return issues.map((i) => `${i.severity === 'error' ? red('error') : yellow('warn ')} ${i.code} ${dim(i.path)} ${i.message}`).join('\n');
57
+ }
58
+ export function printLint(result, title = 'Lint') {
59
+ if (!result || !result.issues?.length)
60
+ return;
61
+ human.note(formatLint(result.issues), title);
62
+ }
63
+ export function lintError(result) {
64
+ const errors = result.issues.filter((i) => i.severity === 'error');
65
+ return new CliError('lint_failed', `${errors.length} lint error${errors.length === 1 ? '' : 's'} — fix them before pushing.`, 1, { extra: { lint: result } });
66
+ }
67
+ /** Extract a lint result from a problem+json 422 body, whatever key the gateway used. */
68
+ export function lintFromError(e) {
69
+ const x = e.extra ?? {};
70
+ for (const k of ['lint', 'issues', 'errors']) {
71
+ const v = x[k];
72
+ if (v && typeof v === 'object' && Array.isArray(v.issues))
73
+ return v;
74
+ if (Array.isArray(v))
75
+ return { ok: false, issues: v };
76
+ }
77
+ return null;
78
+ }
@@ -0,0 +1,85 @@
1
+ import { usage } from "../errors.js";
2
+ import { human, bold, dim, green, red, yellow } from "../output.js";
3
+ import { formatDate, parseIntOpt, table } from "../util.js";
4
+ import { api, finish } from "./_shared.js";
5
+ const STATUSES = ['open', 'acknowledged', 'resolved', 'all'];
6
+ const KINDS = ['new_tracker', 'preconsent', 'unclassified', 'removed', 'install_broken'];
7
+ const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
8
+ const colourSeverity = (s) => (s === 'critical' || s === 'high' ? red(s) : s === 'medium' ? yellow(s) : dim(s));
9
+ function oneOf(value, allowed, flag) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ const v = value.toLowerCase();
13
+ if (!allowed.includes(v))
14
+ throw usage(`${flag} must be one of ${allowed.join(', ')} (got "${value}")`);
15
+ return v;
16
+ }
17
+ /** Human rendering shared by ack/resolve. */
18
+ function describe(a) {
19
+ return [
20
+ `${bold('Alert')} ${a.id}`,
21
+ `${bold('Status')} ${a.status === 'resolved' ? green(a.status) : a.status}`,
22
+ `${bold('Kind')} ${a.kind ?? dim('—')} ${dim(String(a.severity ?? ''))}`,
23
+ `${bold('Site')} ${a.site_id ?? dim('workspace')}`,
24
+ ].join('\n');
25
+ }
26
+ export function register(program) {
27
+ const alerts = program.command('alerts').description('Alert inbox of the workspace');
28
+ alerts
29
+ .command('list', { isDefault: true })
30
+ .description('List alerts (open by default)')
31
+ .option('--site <id>', 'only alerts of this site')
32
+ .option('--status <status>', `open, acknowledged, resolved or all (default: open)`)
33
+ .option('--kind <kind>', `one of ${KINDS.join(', ')}`)
34
+ .option('--severity <level>', `minimum severity: ${SEVERITIES.join(', ')}`)
35
+ .option('--since <date>', 'only alerts raised after this ISO timestamp')
36
+ .option('--limit <n>', 'maximum rows (1..500)', parseIntOpt('--limit'), 50)
37
+ .action(async (opts) => {
38
+ const status = oneOf(opts.status, STATUSES, '--status') ?? 'open';
39
+ const kind = oneOf(opts.kind, KINDS, '--kind');
40
+ const min_severity = oneOf(opts.severity, SEVERITIES, '--severity');
41
+ if (opts.since && Number.isNaN(Date.parse(opts.since)))
42
+ throw usage('--since must be an ISO timestamp');
43
+ const page = await api().alerts({ site_id: opts.site, status, kind, min_severity, since: opts.since, limit: opts.limit });
44
+ finish({ alerts: page.items, counts: page.counts ?? null, status }, () => {
45
+ if (!page.items.length)
46
+ return human.print(dim(`No ${status === 'all' ? '' : status + ' '}alerts.`));
47
+ human.print(table(page.items.map((a) => [
48
+ a.id,
49
+ String(a.kind ?? ''),
50
+ colourSeverity(String(a.severity ?? '')),
51
+ String(a.status ?? ''),
52
+ a.site_id ? a.site_id.slice(0, 8) + '…' : 'workspace',
53
+ formatDate(a.created_at),
54
+ String(a.title ?? '').slice(0, 60),
55
+ ]), ['id', 'kind', 'severity', 'status', 'site', 'raised', 'title']));
56
+ const counts = page.counts ?? {};
57
+ const line = Object.entries(counts)
58
+ .map(([k, v]) => `${k}: ${v}`)
59
+ .join(' ');
60
+ if (line)
61
+ human.print(dim(line));
62
+ });
63
+ });
64
+ alerts
65
+ .command('ack <id>')
66
+ .alias('acknowledge')
67
+ .description('Acknowledge an alert')
68
+ .action(async (id) => {
69
+ const alert = await api().acknowledgeAlert(id);
70
+ finish({ alert }, () => {
71
+ human.success(`Acknowledged ${id}`);
72
+ human.message(describe(alert));
73
+ });
74
+ });
75
+ alerts
76
+ .command('resolve <id>')
77
+ .description('Resolve an alert')
78
+ .action(async (id) => {
79
+ const alert = await api().resolveAlert(id);
80
+ finish({ alert }, () => {
81
+ human.success(`Resolved ${id}`);
82
+ human.message(describe(alert));
83
+ });
84
+ });
85
+ }
@@ -0,0 +1,92 @@
1
+ import { Api } from "../api.js";
2
+ import { clearCredentials, credentialsPath, deviceFlow, orgLabel, resolveToken, writeCredentials, DEFAULT_SCOPES } from "../auth.js";
3
+ import { CliError, usage } from "../errors.js";
4
+ import { ctx, human, dim, bold, isInteractive } from "../output.js";
5
+ import { cliVersion, parseCsvList } from "../util.js";
6
+ import { apiWithSource, finish } from "./_shared.js";
7
+ async function readStdin() {
8
+ const chunks = [];
9
+ for await (const c of process.stdin)
10
+ chunks.push(Buffer.from(c));
11
+ return Buffer.concat(chunks).toString('utf8').trim();
12
+ }
13
+ export function register(program) {
14
+ program
15
+ .command('login')
16
+ .description('Sign in with the device flow, or store a token (--token)')
17
+ .option('--token [token]', 'store an existing cc_* token (reads stdin when no value is given)')
18
+ .option('--scopes <list>', `scopes to request in the device flow (default: ${DEFAULT_SCOPES.join(',')})`)
19
+ .option('--no-open', 'do not open the browser; just print the URL')
20
+ .action(async (opts) => {
21
+ human.intro('login');
22
+ const base = ctx().api;
23
+ let token = null;
24
+ if (opts.token !== undefined) {
25
+ if (typeof opts.token === 'string')
26
+ token = opts.token.trim();
27
+ else if (!isInteractive())
28
+ token = await readStdin();
29
+ else
30
+ throw usage('Pass the token value: --token cc_live_… (or pipe it on stdin in CI)');
31
+ if (!token)
32
+ throw usage('Empty token.');
33
+ }
34
+ if (token) {
35
+ const api = new Api(base, token, cliVersion());
36
+ const me = await api.me();
37
+ const file = writeCredentials({ api: api.base, token, org: me.org, name: me.token.name, token_id: me.token.id, scopes: me.token.scopes, expires_at: me.token.expires_at });
38
+ finish({ api: api.base, org: me.org, token: me.token, credentials: file }, () => {
39
+ human.success(`Signed in to ${bold(orgLabel(me.org))} as token "${me.token.name}" (${me.token.scopes.join(', ')})`);
40
+ human.outro(dim(`Credentials saved to ${file}`));
41
+ });
42
+ return;
43
+ }
44
+ if (ctx().json)
45
+ throw usage('Device login is interactive; use `login --token <token>` (or COOKIECRUMBS_TOKEN) with --json.');
46
+ const anon = new Api(base, null, cliVersion());
47
+ const { token: t } = await deviceFlow(anon, { scopes: parseCsvList(opts.scopes), open: opts.open });
48
+ const file = writeCredentials({ api: anon.base, token: t.token, org: t.org, name: t.name, token_id: t.token_id, scopes: t.scopes, expires_at: t.expires_at });
49
+ human.success(`Signed in to ${bold(orgLabel(t.org))} as token "${t.name}" (${(t.scopes ?? []).join(', ')})`);
50
+ human.outro(dim(`Credentials saved to ${file}`));
51
+ });
52
+ program
53
+ .command('logout')
54
+ .description('Remove the stored credentials')
55
+ .action(async () => {
56
+ const removed = clearCredentials();
57
+ const envSet = Boolean(process.env.COOKIECRUMBS_TOKEN);
58
+ finish({ removed, credentials: credentialsPath(), env_token_still_set: envSet }, () => {
59
+ human.intro('logout');
60
+ if (removed)
61
+ human.success(`Removed ${credentialsPath()}`);
62
+ else
63
+ human.info('No stored credentials.');
64
+ if (envSet)
65
+ human.warn('COOKIECRUMBS_TOKEN is set in this shell and still applies.');
66
+ human.outro('Done');
67
+ });
68
+ });
69
+ program
70
+ .command('whoami')
71
+ .description('Show the token, scopes and workspace in use')
72
+ .action(async () => {
73
+ const { api, source } = apiWithSource();
74
+ const { creds } = resolveToken();
75
+ if (!api.token)
76
+ throw new CliError('not_logged_in', 'Not signed in. Run `cookiecrumbs login` or set COOKIECRUMBS_TOKEN.', 2);
77
+ const me = await api.me();
78
+ finish({ source, api: api.base, org: me.org, token: me.token }, () => {
79
+ human.intro('whoami');
80
+ human.message([
81
+ `${bold('Workspace')} ${orgLabel(me.org)}${me.org.plan ? dim(` plan ${me.org.plan}`) : ''}`,
82
+ `${bold('Token')} ${me.token.name} ${dim(`(${me.token.kind}, id ${me.token.id})`)}`,
83
+ `${bold('Scopes')} ${me.token.scopes.join(', ')}`,
84
+ `${bold('Restricted')} ${me.token.site_id ? `site ${me.token.site_id}` : 'any site'}${me.token.environment ? ` · ${me.token.environment}` : ''}`,
85
+ `${bold('Expires')} ${me.token.expires_at ?? 'never'}`,
86
+ `${bold('Source')} ${source === 'env' ? 'COOKIECRUMBS_TOKEN' : credentialsPath()}${creds?.saved_at ? dim(` saved ${creds.saved_at}`) : ''}`,
87
+ `${bold('API')} ${api.base}`,
88
+ ].join('\n'));
89
+ human.outro('OK');
90
+ });
91
+ });
92
+ }
@@ -0,0 +1,45 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { usage } from "../errors.js";
4
+ import { ctx, human, printJson } from "../output.js";
5
+ import { findProjectRoot, requireState } from "../project.js";
6
+ import { api } from "./_shared.js";
7
+ export function register(program) {
8
+ const declaration = program.command('declaration').description('Cookie declaration');
9
+ declaration
10
+ .command('export')
11
+ .description('Download the current declaration as html, md or json')
12
+ .option('--format <fmt>', 'html, md or json', 'html')
13
+ .option('--lang <lang>', 'language (default: the site default)')
14
+ .option('--out <file>', 'write to a file instead of stdout')
15
+ .action(async (opts) => {
16
+ const fmt = opts.format.toLowerCase();
17
+ if (fmt !== 'html' && fmt !== 'md' && fmt !== 'json')
18
+ throw usage('--format must be html, md or json');
19
+ const { site_id } = requireState(findProjectRoot(ctx().cwd));
20
+ const text = await api().declaration(site_id, fmt, opts.lang);
21
+ let file = null;
22
+ if (opts.out) {
23
+ file = resolve(ctx().cwd, opts.out);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, text.endsWith('\n') ? text : text + '\n');
26
+ }
27
+ if (ctx().json) {
28
+ let parsed = null;
29
+ if (fmt === 'json') {
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch {
34
+ parsed = null;
35
+ }
36
+ }
37
+ printJson({ ok: true, format: fmt, lang: opts.lang ?? null, file, bytes: Buffer.byteLength(text), ...(parsed ? { declaration: parsed } : file ? {} : { content: text }) });
38
+ return;
39
+ }
40
+ if (file)
41
+ human.success(`Wrote ${file}`);
42
+ else
43
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
44
+ });
45
+ }
@@ -0,0 +1,26 @@
1
+ import { diffConfigs, formatDiff, summariseDiff } from "../diff.js";
2
+ import { ctx, human, dim } from "../output.js";
3
+ import { findProjectRoot, loadLocalConfig, requireState } from "../project.js";
4
+ import { api, envOf, finish } from "./_shared.js";
5
+ export function register(program) {
6
+ program
7
+ .command('diff')
8
+ .description('Show what push would change (local config vs the remote draft)')
9
+ .option('--env <env>', 'preview or production (default: the linked environment)')
10
+ .action(async (opts) => {
11
+ const root = findProjectRoot(ctx().cwd);
12
+ const state = requireState(root);
13
+ const env = envOf(opts.env, state.env);
14
+ const local = await loadLocalConfig(root);
15
+ const remote = await api().getDraft(state.site_id, env);
16
+ const changes = diffConfigs(remote.config, local.config);
17
+ finish({ env, changes, summary: summariseDiff(changes), updated_at: remote.updated_at ?? null }, () => {
18
+ if (!changes.length) {
19
+ human.print(dim(`No differences between the local config and the ${env} draft.`));
20
+ return;
21
+ }
22
+ human.print(formatDiff(changes));
23
+ human.print(dim(`${summariseDiff(changes)} (local vs ${env} draft; + added locally, - missing locally)`));
24
+ });
25
+ });
26
+ }
@@ -0,0 +1,44 @@
1
+ import { usage } from "../errors.js";
2
+ import { human, bold, dim, green, yellow } from "../output.js";
3
+ import { formatDate, table } from "../util.js";
4
+ import { api, finish, siteIdOf } from "./_shared.js";
5
+ const METHODS = ['dns_txt', 'meta'];
6
+ function instructions(d) {
7
+ if (d.verified_at)
8
+ return `${green('Verified')} ${formatDate(d.verified_at)}`;
9
+ if (d.verification_method === 'meta')
10
+ return `Add to the <head> of https://${d.hostname}/:\n <meta name="cookiecrumbs-verification" content="${d.verification_token}">`;
11
+ return `Add a DNS TXT record:\n _cookiecrumbs.${d.hostname} TXT "cookiecrumbs-verification=${d.verification_token}"`;
12
+ }
13
+ export function register(program) {
14
+ const domains = program.command('domains').description('The domains of a site and their ownership verification');
15
+ domains
16
+ .command('list', { isDefault: true })
17
+ .description('List domains with their verification state')
18
+ .option('--site <id>', 'site id (default: the linked site)')
19
+ .action(async (opts) => {
20
+ const items = await api().domains(siteIdOf(opts.site));
21
+ finish({ domains: items }, () => {
22
+ if (!items.length)
23
+ return human.print(dim('No domains.'));
24
+ human.print(table(items.map((d) => [d.id, d.hostname, d.verification_method, d.verified_at ? green('verified') : yellow('unverified'), formatDate(d.last_checked_at)]), ['id', 'hostname', 'method', 'state', 'last check']));
25
+ for (const d of items.filter((x) => !x.verified_at))
26
+ human.message(`${bold(d.hostname)}\n${instructions(d)}`);
27
+ });
28
+ });
29
+ domains
30
+ .command('verify <id>')
31
+ .description('(Re)issue the verification token and print what to publish; the worker checks it')
32
+ .option('--method <method>', `${METHODS.join(' or ')} (default: dns_txt)`, 'dns_txt')
33
+ .action(async (id, opts) => {
34
+ if (!METHODS.includes(opts.method))
35
+ throw usage(`--method must be ${METHODS.join(' or ')}`);
36
+ const d = await api().verifyDomain(id, opts.method);
37
+ finish({ domain: d, instructions: instructions(d) }, () => {
38
+ human.success(`${d.hostname}: ${d.verification_method}`);
39
+ human.note(instructions(d), d.verified_at ? 'Already verified' : 'Publish this, then wait for the next check');
40
+ if (!d.verified_at)
41
+ human.outro('A passed install check verifies the domain too, without a DNS record.');
42
+ });
43
+ });
44
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { CliError, usage } from "../errors.js";
4
+ import { ctx, human, bold, dim, yellow } from "../output.js";
5
+ import { findProjectRoot, requireState } from "../project.js";
6
+ import { cliVersion, firstOfMonth, parseIntOpt, toIso } from "../util.js";
7
+ import { api, envOf, finish } from "./_shared.js";
8
+ import { downloadExport, runExportJob } from "./logs.js";
9
+ export function register(program) {
10
+ program
11
+ .command('export')
12
+ .description('Export everything about the linked site into a folder (--all)')
13
+ .option('--all', 'config, texts, declarations, versions, consent log and audit bundle')
14
+ .option('--out <dir>', 'output folder', './cookiecrumbs-export')
15
+ .option('--env <env>', 'preview or production (default: the linked environment)')
16
+ .option('--from <date>', 'consent log range start (default: first day of this month)')
17
+ .option('--to <date>', 'consent log range end (default: now)')
18
+ .option('--skip-logs', 'do not run the consent log / audit bundle export jobs')
19
+ .option('--timeout <seconds>', 'per job wait limit', parseIntOpt('--timeout'), 600)
20
+ .action(async (opts) => {
21
+ if (!opts.all)
22
+ throw usage('Pass --all (the only mode for now): cookiecrumbs export --all [--out dir]');
23
+ human.intro('export --all');
24
+ const root = findProjectRoot(ctx().cwd);
25
+ const state = requireState(root);
26
+ const env = envOf(opts.env, state.env);
27
+ const out = resolve(ctx().cwd, opts.out);
28
+ mkdirSync(out, { recursive: true });
29
+ const a = api();
30
+ const files = [];
31
+ const missing = [];
32
+ const rel = (f) => relative(out, f) || f;
33
+ const write = (name, data) => {
34
+ const f = join(out, name);
35
+ mkdirSync(resolve(f, '..'), { recursive: true });
36
+ writeFileSync(f, data);
37
+ files.push(name);
38
+ human.step(`${name}`);
39
+ };
40
+ // 1. config + texts
41
+ const site = await a.site(state.site_id);
42
+ const draft = await a.getDraft(state.site_id, env);
43
+ write('config.json', JSON.stringify(draft.config, null, 2) + '\n');
44
+ const langs = draft.config.languages ?? [];
45
+ for (const lang of langs) {
46
+ write(join('texts', `${lang}.json`), JSON.stringify({ texts: draft.config.texts?.[lang] ?? {}, category_texts: draft.config.category_texts?.[lang] ?? {} }, null, 2) + '\n');
47
+ }
48
+ // 2. declarations
49
+ for (const lang of langs) {
50
+ for (const fmt of ['html', 'md', 'json']) {
51
+ try {
52
+ const text = await a.declaration(state.site_id, fmt, lang);
53
+ write(`declaration.${lang}.${fmt}`, text.endsWith('\n') ? text : text + '\n');
54
+ }
55
+ catch (e) {
56
+ const reason = e instanceof CliError ? `${e.code}: ${e.detail}` : String(e);
57
+ missing.push({ file: `declaration.${lang}.${fmt}`, reason });
58
+ human.warn(`declaration.${lang}.${fmt}: ${reason}`);
59
+ }
60
+ }
61
+ }
62
+ // 3. versions
63
+ const versions = await a.versions(state.site_id);
64
+ write('versions.json', JSON.stringify(versions, null, 2) + '\n');
65
+ // 4. consent log + audit bundle (sequential: one job per site at a time)
66
+ let from = '';
67
+ let to = '';
68
+ let consents = null;
69
+ let bundle = null;
70
+ if (!opts.skipLogs) {
71
+ try {
72
+ from = toIso(opts.from, firstOfMonth);
73
+ to = toIso(opts.to, () => new Date());
74
+ }
75
+ catch (e) {
76
+ throw usage(e.message);
77
+ }
78
+ for (const [kind, name] of [
79
+ ['consent_jsonl', 'consents.jsonl'],
80
+ ['audit_bundle', 'audit-bundle.zip'],
81
+ ]) {
82
+ try {
83
+ const job = await runExportJob(a, state.site_id, { kind, from, to, env }, { timeoutMs: opts.timeout * 1000, label: name });
84
+ const r = await downloadExport(a, job, join(out, name));
85
+ files.push(name);
86
+ human.step(`${name} ${dim(`sha256 ${r.sha256.slice(0, 16)}… ${r.sha256_verified ? 'verified' : ''}`)}`);
87
+ if (kind === 'consent_jsonl')
88
+ consents = r;
89
+ else
90
+ bundle = r;
91
+ }
92
+ catch (e) {
93
+ const reason = e instanceof CliError ? `${e.code}: ${e.detail}` : String(e);
94
+ missing.push({ file: name, reason });
95
+ human.warn(`${name}: ${reason}`);
96
+ }
97
+ }
98
+ }
99
+ // 5. README
100
+ const readme = [
101
+ `CookieCrumbs export — ${site.name} (${site.id}) · environment ${env}`,
102
+ `Generated ${new Date().toISOString()} by cookiecrumbs CLI ${cliVersion()}`,
103
+ '',
104
+ 'Files',
105
+ ' config.json The current draft banner configuration (BannerConfig v1, JSON).',
106
+ ' texts/<lang>.json Banner texts and category texts per language (same data as in config.json).',
107
+ ' declaration.<lang>.html The hosted cookie declaration as HTML, Markdown (.md) and JSON (.json) per language.',
108
+ ' versions.json Every published version (number, environment, note, hashes, actor) without screenshots.',
109
+ ' consents.jsonl Consent records (one JSON object per line, keys sorted) for the range below, from a signed export job.',
110
+ ' audit-bundle.zip Deterministic audit bundle: records.jsonl, referenced banner versions, declarations, regime rules,',
111
+ ' audit trail, integrity.json, signature.bin and a dependency-free verify.mjs.',
112
+ '',
113
+ `Consent log range: ${from || '—'} → ${to || '—'}`,
114
+ consents ? `consents.jsonl sha256 ${consents.sha256}${consents.signature ? ` Ed25519 signature ${consents.signature} (kid ${consents.kid ?? '?'})` : ''}` : 'consents.jsonl: not exported' + (opts.skipLogs ? ' (--skip-logs)' : ' (see "Missing" below)'),
115
+ bundle ? `audit-bundle.zip sha256 ${bundle.sha256}${bundle.signature ? ` Ed25519 signature ${bundle.signature} (kid ${bundle.kid ?? '?'})` : ''}` : 'audit-bundle.zip: not exported' + (opts.skipLogs ? ' (--skip-logs)' : ' (see "Missing" below)'),
116
+ '',
117
+ 'How to verify',
118
+ ' 1. Hash: openssl dgst -sha256 consents.jsonl (compare with the sha256 above / X-Content-SHA256)',
119
+ ' 2. Signature: the base64 Ed25519 signature covers the 32 digest bytes; the public key (by kid) is published at',
120
+ ' https://nldaffvxmpcghovmgvuc.supabase.co/functions/v1/well-known/cookiecrumbs-signing.json',
121
+ ' openssl dgst -sha256 -binary consents.jsonl > h && openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in h -sigfile sig.bin',
122
+ ' 3. Chain: unzip audit-bundle.zip -d bundle && node bundle/verify.mjs audit-bundle.zip',
123
+ ' (checks every hash in integrity.json, the signature and the hash chain over the consent records)',
124
+ '',
125
+ missing.length ? 'Missing\n' + missing.map((m) => ` ${m.file}: ${m.reason}`).join('\n') : 'All files were exported.',
126
+ '',
127
+ ].join('\n');
128
+ write('README.txt', readme);
129
+ const json = { out, env, files, missing, consents: consents ? { sha256: consents.sha256, signature: consents.signature, kid: consents.kid } : null, audit_bundle: bundle ? { sha256: bundle.sha256, signature: bundle.signature, kid: bundle.kid } : null };
130
+ if (missing.length) {
131
+ human.message(yellow(`${missing.length} file${missing.length === 1 ? '' : 's'} could not be exported — see README.txt`));
132
+ throw new CliError('export_incomplete', `Export written to ${out} but ${missing.map((m) => m.file).join(', ')} ${missing.length === 1 ? 'is' : 'are'} missing.`, 1, { extra: json });
133
+ }
134
+ finish(json, () => human.outro(`Exported ${files.length} files to ${bold(rel(out) === '' ? out : out)}`));
135
+ });
136
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { relative, resolve } from 'node:path';
3
+ import { defaultConfig } from "../configpkg.js";
4
+ import { CliError, usage } from "../errors.js";
5
+ import { detectFramework, installSnippet, FRAMEWORK_LABEL } from "../frameworks.js";
6
+ import { configHash } from "../merge.js";
7
+ import { askConfirm, askSelect, askText, ctx, human, bold, dim } from "../output.js";
8
+ import { CONFIG_TS, CONFIG_JSON, writeLocalConfig, writePulled, writeState, configFile } from "../project.js";
9
+ import { appUrl, runtimeApiBase } from "../util.js";
10
+ import { api, envOf, finish } from "./_shared.js";
11
+ import { pushProject } from "./push.js";
12
+ export function runtimeUrl() {
13
+ return process.env.COOKIECRUMBS_RUNTIME_URL ?? `${appUrl()}/runtime/cc.js`;
14
+ }
15
+ export function snippetFor(framework, site, env, lang) {
16
+ const envRow = (site.environments ?? []).find((e) => e.name === env);
17
+ const key = envRow?.public_key ?? null;
18
+ return {
19
+ key,
20
+ text: installSnippet(framework, { siteKey: key ?? `<public key of ${env}>`, env, runtimeUrl: runtimeUrl(), apiBase: runtimeApiBase(ctx().api), lang }),
21
+ };
22
+ }
23
+ export function register(program) {
24
+ program
25
+ .command('init')
26
+ .description('Create or link a site, write cookiecrumbs.config.ts + texts, print the install snippet')
27
+ .option('--site <id>', 'link an existing site id')
28
+ .option('--env <env>', 'environment to work against (preview|production)', 'preview')
29
+ .option('--name <name>', 'create a new site with this name (with --domain)')
30
+ .option('--domain <domain>', 'primary domain of the new site')
31
+ .option('--format <fmt>', 'config file format: ts or json', 'ts')
32
+ .option('--publish', 'push the config to the environment right away')
33
+ .option('-y, --yes', 'overwrite an existing config without asking')
34
+ .action(async (opts) => {
35
+ human.intro('init');
36
+ const root = resolve(ctx().cwd);
37
+ const env = envOf(opts.env);
38
+ const format = opts.format === 'json' ? 'json' : opts.format === 'ts' || !opts.format ? 'ts' : null;
39
+ if (!format)
40
+ throw usage('--format must be ts or json');
41
+ const a = api();
42
+ const me = await a.me();
43
+ // --- pick or create the site ---------------------------------------
44
+ let site;
45
+ if (opts.site) {
46
+ site = await a.site(opts.site);
47
+ }
48
+ else if (opts.name || opts.domain) {
49
+ if (!opts.name || !opts.domain)
50
+ throw usage('Creating a site needs both --name and --domain.');
51
+ site = await a.createSite({ name: opts.name, primary_domain: opts.domain });
52
+ human.success(`Created site ${bold(site.name)} (${site.id})`);
53
+ }
54
+ else {
55
+ const sites = await a.sites();
56
+ const choice = await askSelect('Which site?', '--site <id> (or --name + --domain to create one)', [
57
+ ...sites.map((s) => ({ value: s.id, label: s.name, hint: s.primary_domain })),
58
+ { value: '__new__', label: 'Create a new site' },
59
+ ]);
60
+ if (choice === '__new__') {
61
+ const name = await askText('Site name', '--name', { validate: (v) => (v.trim() ? undefined : 'Required') });
62
+ const domain = await askText('Primary domain', '--domain', { placeholder: 'www.example.com', validate: (v) => (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v.trim()) ? undefined : 'A hostname, e.g. www.example.com') });
63
+ site = await a.createSite({ name: name.trim(), primary_domain: domain.trim().toLowerCase() });
64
+ human.success(`Created site ${bold(site.name)} (${site.id})`);
65
+ }
66
+ else {
67
+ site = sites.find((s) => s.id === choice) ?? (await a.site(choice));
68
+ }
69
+ }
70
+ // --- existing files -------------------------------------------------
71
+ const existing = configFile(root);
72
+ if (existing && !opts.yes) {
73
+ const ok = await askConfirm(`${relative(root, existing.path) || existing.path} exists. Overwrite it with the ${env} draft?`, '--yes', false);
74
+ if (!ok)
75
+ throw new CliError('cancelled', 'Kept the existing config.', 2);
76
+ }
77
+ // --- config from the current draft (or defaults) ---------------------
78
+ let config;
79
+ let updatedAt = null;
80
+ let source = 'draft';
81
+ try {
82
+ const draft = await a.getDraft(site.id, env);
83
+ config = draft.config;
84
+ updatedAt = draft.updated_at ?? null;
85
+ }
86
+ catch (e) {
87
+ if (e instanceof CliError && e.status === 404) {
88
+ config = defaultConfig(site.default_lang ?? 'en');
89
+ source = 'defaults';
90
+ }
91
+ else
92
+ throw e;
93
+ }
94
+ const framework = detectFramework(root);
95
+ const files = writeLocalConfig(root, config, { format });
96
+ const hash = configHash(config);
97
+ writePulled(root, config);
98
+ writeState(root, {
99
+ site_id: site.id,
100
+ env,
101
+ last_pulled_hash: hash,
102
+ last_pulled_updated_at: updatedAt,
103
+ site_name: site.name,
104
+ org_slug: me.org.slug,
105
+ public_keys: Object.fromEntries((site.environments ?? []).map((e) => [e.name, e.public_key])),
106
+ });
107
+ const snippet = snippetFor(framework.framework, site, env, config.default_lang);
108
+ let version = null;
109
+ if (opts.publish) {
110
+ const r = await pushProject(root, { env, note: 'Initial push from the CLI' });
111
+ version = r.version;
112
+ }
113
+ finish({
114
+ site: { id: site.id, name: site.name, primary_domain: site.primary_domain },
115
+ env,
116
+ org: me.org,
117
+ config_source: source,
118
+ framework: framework.framework,
119
+ framework_reason: framework.reason,
120
+ files: files.map((f) => relative(root, f)),
121
+ public_key: snippet.key,
122
+ snippet: snippet.text,
123
+ version,
124
+ }, () => {
125
+ human.success(`Linked ${bold(site.name)} (${site.id}) · ${env} · config from ${source === 'draft' ? 'the current draft' : 'defaults'}`);
126
+ human.step(`Wrote ${files.map((f) => relative(root, f)).join(', ')}`);
127
+ human.info(`Detected ${bold(FRAMEWORK_LABEL[framework.framework])} ${dim(`(${framework.reason})`)}`);
128
+ human.note(snippet.text, `Install snippet (${FRAMEWORK_LABEL[framework.framework]}, ${env})`);
129
+ if (!snippet.key)
130
+ human.warn(`The site row carried no ${env} public key; replace the placeholder from Settings → Install.`);
131
+ human.outro(`Edit ${existsSync(resolve(root, CONFIG_TS)) ? CONFIG_TS : CONFIG_JSON} and run ${bold('cookiecrumbs push')}`);
132
+ });
133
+ });
134
+ }