@cookiecrumbs-eu/mcp 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +134 -0
- package/README.md +186 -0
- package/dist/cli/src/api.js +192 -0
- package/dist/cli/src/auth.js +106 -0
- package/dist/cli/src/commands/_shared.js +78 -0
- package/dist/cli/src/commands/alerts.js +85 -0
- package/dist/cli/src/commands/auth.js +92 -0
- package/dist/cli/src/commands/declaration.js +45 -0
- package/dist/cli/src/commands/diff.js +26 -0
- package/dist/cli/src/commands/domains.js +44 -0
- package/dist/cli/src/commands/export.js +136 -0
- package/dist/cli/src/commands/init.js +134 -0
- package/dist/cli/src/commands/install.js +77 -0
- package/dist/cli/src/commands/issues.js +61 -0
- package/dist/cli/src/commands/link.js +41 -0
- package/dist/cli/src/commands/logs.js +98 -0
- package/dist/cli/src/commands/open.js +45 -0
- package/dist/cli/src/commands/pull.js +89 -0
- package/dist/cli/src/commands/push.js +110 -0
- package/dist/cli/src/commands/scan.js +94 -0
- package/dist/cli/src/commands/schedule.js +97 -0
- package/dist/cli/src/commands/services.js +143 -0
- package/dist/cli/src/commands/sites.js +111 -0
- package/dist/cli/src/commands/status.js +90 -0
- package/dist/cli/src/commands/templates.js +133 -0
- package/dist/cli/src/commands/tokens.js +50 -0
- package/dist/cli/src/commands/usage.js +41 -0
- package/dist/cli/src/commands/versions.js +95 -0
- package/dist/cli/src/commands/webhooks.js +164 -0
- package/dist/cli/src/configpkg.js +10 -0
- package/dist/cli/src/diff.js +63 -0
- package/dist/cli/src/errors.js +20 -0
- package/dist/cli/src/frameworks.js +141 -0
- package/dist/cli/src/index.js +100 -0
- package/dist/cli/src/jobs.js +59 -0
- package/dist/cli/src/merge.js +38 -0
- package/dist/cli/src/output.js +112 -0
- package/dist/cli/src/project.js +269 -0
- package/dist/cli/src/util.js +122 -0
- package/dist/config/rules_reference.json +569 -0
- package/dist/config/src/canon.js +36 -0
- package/dist/config/src/declaration.js +38 -0
- package/dist/config/src/defaults.js +804 -0
- package/dist/config/src/export.js +130 -0
- package/dist/config/src/index.js +16 -0
- package/dist/config/src/lint.js +139 -0
- package/dist/config/src/regimes.js +62 -0
- package/dist/config/src/rules.js +90 -0
- package/dist/config/src/schema.js +323 -0
- package/dist/config/src/theme.js +147 -0
- package/dist/config/src/verify.js +51 -0
- package/dist/config/src/webhooks.js +309 -0
- package/dist/mcp/src/auth.js +40 -0
- package/dist/mcp/src/client.js +44 -0
- package/dist/mcp/src/diff.js +134 -0
- package/dist/mcp/src/index.js +25 -0
- package/dist/mcp/src/matrix.js +106 -0
- package/dist/mcp/src/server.js +171 -0
- package/dist/mcp/src/shared.js +147 -0
- package/dist/mcp/src/tools-config.js +943 -0
- package/dist/mcp/src/tools.js +650 -0
- package/package.json +66 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { usage as usageError } from "../errors.js";
|
|
2
|
+
import { human, bold, dim, red, yellow } from "../output.js";
|
|
3
|
+
import { table } from "../util.js";
|
|
4
|
+
import { api, finish } from "./_shared.js";
|
|
5
|
+
const pct = (m) => {
|
|
6
|
+
if (typeof m.pct === 'number')
|
|
7
|
+
return m.pct;
|
|
8
|
+
if (m.limit == null || m.limit <= 0)
|
|
9
|
+
return null;
|
|
10
|
+
return (m.used / m.limit) * 100;
|
|
11
|
+
};
|
|
12
|
+
/** 20 blocks; unlimited meters print a dash rather than a fake full bar. */
|
|
13
|
+
function bar(p) {
|
|
14
|
+
if (p == null)
|
|
15
|
+
return dim('unlimited');
|
|
16
|
+
const filled = Math.max(0, Math.min(20, Math.round((p / 100) * 20)));
|
|
17
|
+
const s = '█'.repeat(filled) + '·'.repeat(20 - filled);
|
|
18
|
+
return p >= 100 ? red(s) : p >= 80 ? yellow(s) : s;
|
|
19
|
+
}
|
|
20
|
+
export function register(program) {
|
|
21
|
+
program
|
|
22
|
+
.command('usage')
|
|
23
|
+
.description('Usage meters of the workspace this token belongs to')
|
|
24
|
+
.option('--period <YYYY-MM>', 'billing period (default: the current one)')
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
if (opts.period && !/^\d{4}-\d{2}(-\d{2})?$/.test(opts.period))
|
|
27
|
+
throw usageError('--period must be YYYY-MM');
|
|
28
|
+
const u = await api().usage(opts.period);
|
|
29
|
+
const meters = Array.isArray(u.meters) ? u.meters : [];
|
|
30
|
+
finish({ usage: u }, () => {
|
|
31
|
+
human.print(`${bold('Workspace')} ${u.org_id ?? dim('—')} ${bold('Plan')} ${u.plan ?? dim('—')} ${bold('Period')} ${u.period ?? opts.period ?? dim('current')}`);
|
|
32
|
+
if (!meters.length)
|
|
33
|
+
return human.print(dim('No meters reported for this period.'));
|
|
34
|
+
human.print(table(meters.map((m) => {
|
|
35
|
+
const p = pct(m);
|
|
36
|
+
return [m.metric, String(m.used ?? 0), m.limit == null || m.limit < 0 ? '∞' : String(m.limit), p == null ? '—' : `${p.toFixed(1)}%`, bar(p)];
|
|
37
|
+
}), ['metric', 'used', 'limit', 'pct', '']));
|
|
38
|
+
human.print(dim('Meters are informational: exceeding a visitor meter never blocks the banner, /config or /consent.'));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CliError, usage } from "../errors.js";
|
|
2
|
+
import { ctx, human, bold, dim } from "../output.js";
|
|
3
|
+
import { findProjectRoot, requireState } from "../project.js";
|
|
4
|
+
import { formatDate, table } from "../util.js";
|
|
5
|
+
import { api, envOf, finish } from "./_shared.js";
|
|
6
|
+
function parseNumber(n) {
|
|
7
|
+
const v = Number.parseInt(n.replace(/^v/i, ''), 10);
|
|
8
|
+
if (!Number.isFinite(v) || v < 1)
|
|
9
|
+
throw usage(`Version number expected (got "${n}")`);
|
|
10
|
+
return v;
|
|
11
|
+
}
|
|
12
|
+
async function findVersion(siteId, number) {
|
|
13
|
+
const rows = await api().versions(siteId);
|
|
14
|
+
const v = rows.find((r) => r.number === number);
|
|
15
|
+
if (!v)
|
|
16
|
+
throw new CliError('version_not_found', `No version ${number} on this site.`, 1);
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
/** config_versions has no environment column (publications carry it); show what the row offers. */
|
|
20
|
+
const envOfRow = (v) => String(v.environment ?? v.env ?? v.published_to ?? '—');
|
|
21
|
+
const actor = (v) => {
|
|
22
|
+
const who = v.actor_label ?? (typeof v.created_by === 'string' ? v.created_by : '');
|
|
23
|
+
const via = v.channel ? `via ${String(v.channel).toUpperCase() === 'CLI' ? 'CLI' : v.channel}` : '';
|
|
24
|
+
return [via, who].filter(Boolean).join(' · ');
|
|
25
|
+
};
|
|
26
|
+
export function register(program) {
|
|
27
|
+
const versions = program.command('versions').description('List, inspect, roll back or promote published versions');
|
|
28
|
+
versions
|
|
29
|
+
.command('list', { isDefault: true })
|
|
30
|
+
.description('List versions of the linked site')
|
|
31
|
+
.option('--env <env>', 'only this environment')
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const { site_id } = requireState(findProjectRoot(ctx().cwd));
|
|
34
|
+
let rows = await api().versions(site_id);
|
|
35
|
+
if (opts.env) {
|
|
36
|
+
const env = envOf(opts.env);
|
|
37
|
+
rows = rows.filter((r) => r.environment === env);
|
|
38
|
+
}
|
|
39
|
+
rows.sort((a, b) => b.number - a.number);
|
|
40
|
+
finish({ versions: rows.map((r) => ({ ...r, config: undefined, texts: undefined })) }, () => {
|
|
41
|
+
if (!rows.length)
|
|
42
|
+
return human.print(dim('No versions yet. Run `cookiecrumbs push`.'));
|
|
43
|
+
human.print(table(rows.map((r) => [`v${r.number}`, envOfRow(r), formatDate(r.created_at ?? r.published_at), actor(r), r.note ?? '']), ['version', 'env', 'published', 'by', 'note']));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
versions
|
|
47
|
+
.command('show <n>')
|
|
48
|
+
.description('Show one version (full row with --json)')
|
|
49
|
+
.action(async (n) => {
|
|
50
|
+
const { site_id } = requireState(findProjectRoot(ctx().cwd));
|
|
51
|
+
const found = await findVersion(site_id, parseNumber(n));
|
|
52
|
+
const v = await api().version(found.id).catch(() => found);
|
|
53
|
+
finish({ version: v }, () => {
|
|
54
|
+
const c = v.config;
|
|
55
|
+
human.message([
|
|
56
|
+
`${bold(`v${v.number}`)} · ${envOfRow(v)} · ${formatDate(v.created_at ?? v.published_at)}`,
|
|
57
|
+
`${bold('Id')} ${v.id}`,
|
|
58
|
+
`${bold('Note')} ${v.note ?? dim('—')}${v.material || v.material_change ? ' (material)' : ''}`,
|
|
59
|
+
`${bold('By')} ${actor(v) || dim('—')}`,
|
|
60
|
+
`${bold('Hash')} ${v.config_hash ?? dim('—')}`,
|
|
61
|
+
c ? `${bold('Config')} layout ${c.layout} · ${c.languages.join(', ')} · ${c.categories.map((x) => x.key).join(', ')}` : '',
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join('\n'));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
versions
|
|
68
|
+
.command('rollback <n>')
|
|
69
|
+
.description('Publish a new version copied from version n')
|
|
70
|
+
.option('--env <env>', 'target environment (default: the linked environment)')
|
|
71
|
+
.action(async (n, opts) => {
|
|
72
|
+
human.intro('versions rollback');
|
|
73
|
+
const state = requireState(findProjectRoot(ctx().cwd));
|
|
74
|
+
const env = envOf(opts.env, state.env);
|
|
75
|
+
const found = await findVersion(state.site_id, parseNumber(n));
|
|
76
|
+
const v = await api().rollback(found.id, env);
|
|
77
|
+
finish({ env, from: found.number, version: { ...v, config: undefined, texts: undefined } }, () => {
|
|
78
|
+
human.success(`Restored v${found.number} as ${bold(`v${v.number}`)} on ${env}`);
|
|
79
|
+
human.outro('Run `cookiecrumbs pull` to refresh the local config.');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
versions
|
|
83
|
+
.command('promote <n>')
|
|
84
|
+
.description('Publish an existing preview version to production (no new version row)')
|
|
85
|
+
.action(async (n) => {
|
|
86
|
+
human.intro('versions promote');
|
|
87
|
+
const state = requireState(findProjectRoot(ctx().cwd));
|
|
88
|
+
const found = await findVersion(state.site_id, parseNumber(n));
|
|
89
|
+
const r = await api().promote(found.id);
|
|
90
|
+
finish({ version: found.number, result: r }, () => {
|
|
91
|
+
human.success(`Promoted ${bold(`v${found.number}`)} to production`);
|
|
92
|
+
human.outro('Done');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { CliError, usage } from "../errors.js";
|
|
4
|
+
import { askConfirm, ctx, human, bold, dim, green, red, yellow } from "../output.js";
|
|
5
|
+
import { formatDate, parseCsvList, parseIntOpt, table } from "../util.js";
|
|
6
|
+
import { verifyWebhook, WEBHOOK_EVENT_TYPES } from "../configpkg.js";
|
|
7
|
+
import { api, finish } from "./_shared.js";
|
|
8
|
+
const STORE_IT_NOW = 'Store it now — the secret is shown once and cannot be read back.';
|
|
9
|
+
const statusColour = (s) => s === 'delivered' ? green(s) : s === 'dead' || s === 'failed' ? red(String(s)) : s === 'pending' ? yellow(s) : dim(String(s ?? '—'));
|
|
10
|
+
function checkEvents(list) {
|
|
11
|
+
const events = list && list.length ? list : ['*'];
|
|
12
|
+
const unknown = events.filter((e) => e !== '*' && !WEBHOOK_EVENT_TYPES.includes(e));
|
|
13
|
+
if (unknown.length)
|
|
14
|
+
throw usage(`Unknown event type(s): ${unknown.join(', ')}. Known types: ${WEBHOOK_EVENT_TYPES.join(', ')} (or * for all).`);
|
|
15
|
+
return events;
|
|
16
|
+
}
|
|
17
|
+
/** create/rotate answer {id, secret, endpoint:{…}} — take the row from wherever it is. */
|
|
18
|
+
const endpointOf = (r) => (r.endpoint ?? r);
|
|
19
|
+
function describeEndpoint(e) {
|
|
20
|
+
return [
|
|
21
|
+
`${bold('Endpoint')} ${e.id}`,
|
|
22
|
+
`${bold('URL')} ${e.url}`,
|
|
23
|
+
`${bold('Events')} ${(e.events ?? []).join(', ') || dim('—')}`,
|
|
24
|
+
`${bold('Scope')} ${e.site_id ? `site ${e.site_id}` : 'whole workspace'}${e.enabled === false ? ' ' + yellow('(disabled)') : ''}`,
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
/** Secrets are printed only from the two responses that carry one, and never under --json without the flag. */
|
|
28
|
+
function printSecret(secret, what) {
|
|
29
|
+
if (!secret) {
|
|
30
|
+
human.warn(`${what} did not return a secret — nothing to store.`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
human.note(`${secret}\n\n${STORE_IT_NOW}`, what);
|
|
34
|
+
}
|
|
35
|
+
export function register(program) {
|
|
36
|
+
const webhooks = program.command('webhooks').description('Webhook endpoints and their deliveries');
|
|
37
|
+
webhooks
|
|
38
|
+
.command('list', { isDefault: true })
|
|
39
|
+
.description('List endpoints (never their secrets)')
|
|
40
|
+
.option('--site <id>', 'only endpoints of this site')
|
|
41
|
+
.action(async (opts) => {
|
|
42
|
+
const items = await api().webhooks(opts.site);
|
|
43
|
+
finish({ webhooks: items }, () => {
|
|
44
|
+
if (!items.length)
|
|
45
|
+
return human.print(dim('No webhook endpoints.'));
|
|
46
|
+
human.print(table(items.map((e) => [
|
|
47
|
+
e.id,
|
|
48
|
+
e.url,
|
|
49
|
+
(e.events ?? []).join(','),
|
|
50
|
+
e.site_id ? e.site_id.slice(0, 8) + '…' : 'workspace',
|
|
51
|
+
e.enabled === false ? 'disabled' : 'enabled',
|
|
52
|
+
formatDate(e.last_delivery_at),
|
|
53
|
+
statusColour(e.last_status),
|
|
54
|
+
]), ['id', 'url', 'events', 'scope', 'state', 'last delivery', 'last status']));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
webhooks
|
|
58
|
+
.command('create <url>')
|
|
59
|
+
.description('Create an endpoint; the signing secret is printed once')
|
|
60
|
+
.option('--events <list>', `comma-separated event types (default: * = all). Known: ${WEBHOOK_EVENT_TYPES.join(', ')}`)
|
|
61
|
+
.option('--site <id>', 'restrict the endpoint to one site')
|
|
62
|
+
.option('--description <text>', 'what this endpoint is for')
|
|
63
|
+
.action(async (url, opts) => {
|
|
64
|
+
const events = checkEvents(parseCsvList(opts.events));
|
|
65
|
+
const created = await api().createWebhook({ url, events, site_id: opts.site ?? null, description: opts.description ?? null });
|
|
66
|
+
const endpoint = endpointOf(created);
|
|
67
|
+
finish({ webhook: { ...created }, endpoint_id: endpoint.id ?? created.id ?? null, secret_shown_once: Boolean(created.secret) }, () => {
|
|
68
|
+
human.success(`Created endpoint ${endpoint.id ?? created.id ?? ''}`.trim());
|
|
69
|
+
human.message(describeEndpoint(endpoint));
|
|
70
|
+
printSecret(created.secret, 'Signing secret');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
webhooks
|
|
74
|
+
.command('rotate <id>')
|
|
75
|
+
.description('Replace the signing secret; the new one is printed once')
|
|
76
|
+
.action(async (id) => {
|
|
77
|
+
const rotated = await api().rotateWebhook(id);
|
|
78
|
+
finish({ webhook_id: id, secret: rotated.secret ?? null, secret_shown_once: Boolean(rotated.secret) }, () => {
|
|
79
|
+
human.success(`Rotated the secret of ${id}`);
|
|
80
|
+
printSecret(rotated.secret, 'New signing secret');
|
|
81
|
+
human.warn('Deliveries signed with the previous secret stop verifying as soon as the worker picks the new one up.');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
webhooks
|
|
85
|
+
.command('delete <id>')
|
|
86
|
+
.description('Delete an endpoint and its delivery history')
|
|
87
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
88
|
+
.action(async (id, opts) => {
|
|
89
|
+
if (!opts.yes && !ctx().json) {
|
|
90
|
+
const ok = await askConfirm(`Delete endpoint ${id} and its deliveries?`, '--yes', false);
|
|
91
|
+
if (!ok)
|
|
92
|
+
throw new CliError('cancelled', 'Cancelled.', 2);
|
|
93
|
+
}
|
|
94
|
+
await api().deleteWebhook(id);
|
|
95
|
+
finish({ deleted: id }, () => human.success(`Deleted endpoint ${id}`));
|
|
96
|
+
});
|
|
97
|
+
webhooks
|
|
98
|
+
.command('deliveries <id>')
|
|
99
|
+
.description('Recent deliveries of an endpoint with status, attempt and next retry')
|
|
100
|
+
.option('--limit <n>', 'maximum rows (1..500)', parseIntOpt('--limit'), 20)
|
|
101
|
+
.action(async (id, opts) => {
|
|
102
|
+
const items = await api().webhookDeliveries(id, opts.limit);
|
|
103
|
+
finish({ endpoint_id: id, deliveries: items }, () => {
|
|
104
|
+
if (!items.length)
|
|
105
|
+
return human.print(dim('No deliveries yet.'));
|
|
106
|
+
human.print(table(items.map((d) => [
|
|
107
|
+
d.id,
|
|
108
|
+
d.event_type ?? '',
|
|
109
|
+
statusColour(d.status),
|
|
110
|
+
String(d.attempt ?? 0),
|
|
111
|
+
d.response_code == null ? '—' : String(d.response_code),
|
|
112
|
+
d.response_ms == null ? '—' : `${d.response_ms}ms`,
|
|
113
|
+
formatDate(d.created_at),
|
|
114
|
+
formatDate(d.next_retry_at),
|
|
115
|
+
String(d.error ?? '').slice(0, 40),
|
|
116
|
+
]), ['id', 'event', 'status', 'try', 'code', 'time', 'created', 'next retry', 'error']));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
webhooks
|
|
120
|
+
.command('redeliver <id> <delivery>')
|
|
121
|
+
.description('Queue the same event again as a new delivery')
|
|
122
|
+
.action(async (id, delivery) => {
|
|
123
|
+
const created = await api().redeliverWebhook(id, delivery);
|
|
124
|
+
finish({ delivery: created }, () => {
|
|
125
|
+
human.success(`Queued a redelivery of ${delivery}`);
|
|
126
|
+
human.message(`${bold('New delivery')} ${created.id ?? dim('—')} ${statusColour(created.status)}`);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
// ---- verify-webhook (offline; no API call) --------------------------------------------------
|
|
130
|
+
program
|
|
131
|
+
.command('verify-webhook')
|
|
132
|
+
.description('Verify a Standard Webhooks request against a signing secret (offline)')
|
|
133
|
+
.requiredOption('--secret <whsec>', 'the endpoint signing secret (whsec_…)')
|
|
134
|
+
.requiredOption('--file <path>', 'file with the RAW request body exactly as received ("-" for stdin)')
|
|
135
|
+
.requiredOption('--headers <json>', 'the request headers as a JSON object, or @file with that JSON')
|
|
136
|
+
.option('--tolerance <seconds>', 'clock tolerance; 0 disables the timestamp check', parseIntOpt('--tolerance'), 300)
|
|
137
|
+
.action(async (opts) => {
|
|
138
|
+
const body = opts.file === '-' ? readFileSync(0, 'utf8') : readFileSync(resolve(ctx().cwd, opts.file), 'utf8');
|
|
139
|
+
const rawHeaders = opts.headers.startsWith('@') ? readFileSync(resolve(ctx().cwd, opts.headers.slice(1)), 'utf8') : opts.headers;
|
|
140
|
+
let headers;
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(rawHeaders);
|
|
143
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
144
|
+
throw new Error('not an object');
|
|
145
|
+
headers = parsed;
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
throw usage(`--headers must be a JSON object (or @file containing one): ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
const result = verifyWebhook({ headers, body, secret: opts.secret, toleranceSeconds: opts.tolerance });
|
|
151
|
+
// Never print "verified" for something that did not verify; the reporter renders this once.
|
|
152
|
+
if (!result.ok)
|
|
153
|
+
throw new CliError(result.reason, `Not verified (${result.reason}): ${result.message}`, 1, { extra: { verified: false, reason: result.reason } });
|
|
154
|
+
finish({ verified: true, id: result.id, timestamp: result.timestamp, signature: result.signature, bytes: Buffer.byteLength(body) }, () => {
|
|
155
|
+
human.success('Signature verified');
|
|
156
|
+
human.message([
|
|
157
|
+
`${bold('webhook-id')} ${result.id}`,
|
|
158
|
+
`${bold('webhook-timestamp')} ${result.timestamp} ${dim(new Date(result.timestamp * 1000).toISOString())}`,
|
|
159
|
+
`${bold('signature')} ${result.signature}`,
|
|
160
|
+
`${bold('body')} ${Buffer.byteLength(body)} bytes`,
|
|
161
|
+
].join('\n'));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single import point for @cookiecrumbs/config. The package ships TypeScript
|
|
3
|
+
* sources (its `exports` map points at src/*.ts), so the CLI compiles it into
|
|
4
|
+
* its own dist/ through a relative import; tsc rewrites the extension.
|
|
5
|
+
* Only exports that exist today are used here — the config package is being
|
|
6
|
+
* extended in parallel and anything newer is treated as optional.
|
|
7
|
+
*/
|
|
8
|
+
export { BannerConfigSchema, defaultConfig, lintConfig, defineConfig, TEXT_KEYS } from "../../config/src/index.js";
|
|
9
|
+
/** Standard Webhooks verifier (phase 5) — one implementation, shared with the worker and the sample receiver. */
|
|
10
|
+
export { verifyWebhook, WEBHOOK_EVENT_TYPES, WEBHOOK_ID_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TOLERANCE_SECONDS } from "../../config/src/webhooks.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
function isPlainObject(v) {
|
|
3
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
4
|
+
}
|
|
5
|
+
/** Flatten to dotted paths; arrays become `path.0`, `path.1`, … */
|
|
6
|
+
export function flatten(value, prefix = '', out = new Map()) {
|
|
7
|
+
if (isPlainObject(value)) {
|
|
8
|
+
const keys = Object.keys(value).sort();
|
|
9
|
+
if (keys.length === 0 && prefix)
|
|
10
|
+
out.set(prefix, {});
|
|
11
|
+
for (const k of keys)
|
|
12
|
+
flatten(value[k], prefix ? `${prefix}.${k}` : k, out);
|
|
13
|
+
}
|
|
14
|
+
else if (Array.isArray(value)) {
|
|
15
|
+
if (value.length === 0 && prefix)
|
|
16
|
+
out.set(prefix, []);
|
|
17
|
+
value.forEach((v, i) => flatten(v, prefix ? `${prefix}.${i}` : String(i), out));
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
out.set(prefix, value);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
25
|
+
export function diffConfigs(from, to) {
|
|
26
|
+
const a = flatten(from);
|
|
27
|
+
const b = flatten(to);
|
|
28
|
+
const paths = new Set([...a.keys(), ...b.keys()]);
|
|
29
|
+
const entries = [];
|
|
30
|
+
for (const path of [...paths].sort()) {
|
|
31
|
+
const inA = a.has(path);
|
|
32
|
+
const inB = b.has(path);
|
|
33
|
+
if (inA && !inB)
|
|
34
|
+
entries.push({ op: 'remove', path, from: a.get(path) });
|
|
35
|
+
else if (!inA && inB)
|
|
36
|
+
entries.push({ op: 'add', path, to: b.get(path) });
|
|
37
|
+
else if (!same(a.get(path), b.get(path)))
|
|
38
|
+
entries.push({ op: 'change', path, from: a.get(path), to: b.get(path) });
|
|
39
|
+
}
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
const show = (v) => {
|
|
43
|
+
const s = JSON.stringify(v);
|
|
44
|
+
return s.length > 80 ? s.slice(0, 77) + '…' : s;
|
|
45
|
+
};
|
|
46
|
+
export function formatDiff(entries, colour = true) {
|
|
47
|
+
if (entries.length === 0)
|
|
48
|
+
return '';
|
|
49
|
+
const c = colour ? pc : { green: (s) => s, red: (s) => s, yellow: (s) => s };
|
|
50
|
+
return entries
|
|
51
|
+
.map((e) => {
|
|
52
|
+
if (e.op === 'add')
|
|
53
|
+
return c.green(`+ ${e.path}: ${show(e.to)}`);
|
|
54
|
+
if (e.op === 'remove')
|
|
55
|
+
return c.red(`- ${e.path}: ${show(e.from)}`);
|
|
56
|
+
return c.yellow(`~ ${e.path}: ${show(e.from)} → ${show(e.to)}`);
|
|
57
|
+
})
|
|
58
|
+
.join('\n');
|
|
59
|
+
}
|
|
60
|
+
export function summariseDiff(entries) {
|
|
61
|
+
const n = (op) => entries.filter((e) => e.op === op).length;
|
|
62
|
+
return `${n('change')} changed, ${n('add')} added, ${n('remove')} removed`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
detail;
|
|
4
|
+
exit;
|
|
5
|
+
status;
|
|
6
|
+
extra;
|
|
7
|
+
constructor(code, detail, exit = 1, opts = {}) {
|
|
8
|
+
super(detail);
|
|
9
|
+
this.name = 'CliError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.detail = detail;
|
|
12
|
+
this.exit = exit;
|
|
13
|
+
this.status = opts.status;
|
|
14
|
+
this.extra = opts.extra;
|
|
15
|
+
}
|
|
16
|
+
toJSON() {
|
|
17
|
+
return { code: this.code, detail: this.detail, exit: this.exit, ...(this.status ? { status: this.status } : {}), ...(this.extra ?? {}) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const usage = (detail, code = 'usage') => new CliError(code, detail, 2);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const FILE_HINTS = [
|
|
4
|
+
['next', ['next.config.js', 'next.config.mjs', 'next.config.ts', 'app/layout.tsx', 'app/layout.jsx', 'pages/_app.tsx', 'pages/_app.jsx']],
|
|
5
|
+
['nuxt', ['nuxt.config.ts', 'nuxt.config.js', 'app.vue']],
|
|
6
|
+
['sveltekit', ['svelte.config.js', 'src/app.html']],
|
|
7
|
+
['astro', ['astro.config.mjs', 'astro.config.ts', 'astro.config.js']],
|
|
8
|
+
['react', ['src/main.tsx', 'src/main.jsx', 'src/App.tsx', 'src/App.jsx']],
|
|
9
|
+
];
|
|
10
|
+
/** Framework from package.json dependencies first, then file hints, else plain. */
|
|
11
|
+
export function detectFramework(root) {
|
|
12
|
+
const pkgPath = join(root, 'package.json');
|
|
13
|
+
if (existsSync(pkgPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
16
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
17
|
+
if (deps.next)
|
|
18
|
+
return { framework: 'next', reason: 'package.json depends on next' };
|
|
19
|
+
if (deps.nuxt || deps.nuxt3)
|
|
20
|
+
return { framework: 'nuxt', reason: 'package.json depends on nuxt' };
|
|
21
|
+
if (deps['@sveltejs/kit'])
|
|
22
|
+
return { framework: 'sveltekit', reason: 'package.json depends on @sveltejs/kit' };
|
|
23
|
+
if (deps.astro)
|
|
24
|
+
return { framework: 'astro', reason: 'package.json depends on astro' };
|
|
25
|
+
if (deps.react || deps['react-dom'])
|
|
26
|
+
return { framework: 'react', reason: 'package.json depends on react' };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* fall through to file hints */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const [framework, files] of FILE_HINTS) {
|
|
33
|
+
const hit = files.find((f) => existsSync(join(root, f)));
|
|
34
|
+
if (hit)
|
|
35
|
+
return { framework, reason: `found ${hit}` };
|
|
36
|
+
}
|
|
37
|
+
return { framework: 'plain', reason: 'no framework markers found' };
|
|
38
|
+
}
|
|
39
|
+
export const FRAMEWORK_LABEL = {
|
|
40
|
+
next: 'Next.js',
|
|
41
|
+
react: 'React',
|
|
42
|
+
astro: 'Astro',
|
|
43
|
+
nuxt: 'Nuxt',
|
|
44
|
+
sveltekit: 'SvelteKit',
|
|
45
|
+
plain: 'plain HTML',
|
|
46
|
+
};
|
|
47
|
+
function tag(i) {
|
|
48
|
+
return `<script src="${i.runtimeUrl}" data-cc-site="${i.siteKey}" data-cc-env="${i.env}" data-cc-api="${i.apiBase}"></script>`;
|
|
49
|
+
}
|
|
50
|
+
/** Install snippet text per framework (plain text; the CLI prints it as a note). */
|
|
51
|
+
export function installSnippet(framework, i) {
|
|
52
|
+
const declaration = `<div data-cc-declaration${i.lang ? ` data-cc-lang="${i.lang}"` : ''}></div>`;
|
|
53
|
+
switch (framework) {
|
|
54
|
+
case 'next':
|
|
55
|
+
return [
|
|
56
|
+
'// npm i @cookiecrumbs/next',
|
|
57
|
+
'// app/layout.tsx — loads the banner with next/script beforeInteractive, so blocking works on first paint',
|
|
58
|
+
"import { CookieCrumbs } from '@cookiecrumbs/next';",
|
|
59
|
+
'',
|
|
60
|
+
'export default function RootLayout({ children }: { children: React.ReactNode }) {',
|
|
61
|
+
' return (',
|
|
62
|
+
' <html lang="en">',
|
|
63
|
+
' <head>',
|
|
64
|
+
` <CookieCrumbs site="${i.siteKey}" env="${i.env}" api="${i.apiBase}" runtimeUrl="${i.runtimeUrl}" />`,
|
|
65
|
+
' </head>',
|
|
66
|
+
' <body>{children}</body>',
|
|
67
|
+
' </html>',
|
|
68
|
+
' );',
|
|
69
|
+
'}',
|
|
70
|
+
'',
|
|
71
|
+
"// Server components can gate a script before it is ever rendered: import { getConsent } from '@cookiecrumbs/next/server'",
|
|
72
|
+
"// Route handlers: createMyConsentRoute() at app/cookiecrumbs/my-consent/route.ts, createGpcRoute() at app/.well-known/gpc.json/route.ts",
|
|
73
|
+
"// Client hooks (useConsent, ConsentGate) come from '@cookiecrumbs/next/client'.",
|
|
74
|
+
'',
|
|
75
|
+
'// Optional, anywhere in a page: the cookie declaration and a reopen control',
|
|
76
|
+
`// ${declaration}`,
|
|
77
|
+
'// <button data-cc-open>Cookie settings</button>',
|
|
78
|
+
].join('\n');
|
|
79
|
+
case 'react':
|
|
80
|
+
return [
|
|
81
|
+
'<!-- index.html — before any other script so blocking applies to every tag -->',
|
|
82
|
+
'<head>',
|
|
83
|
+
` ${tag(i)}`,
|
|
84
|
+
'</head>',
|
|
85
|
+
'',
|
|
86
|
+
'<!-- Optional, anywhere in the app: -->',
|
|
87
|
+
declaration,
|
|
88
|
+
'<button data-cc-open>Cookie settings</button>',
|
|
89
|
+
'<!-- Or install @cookiecrumbs/react and render <CookieCrumbs site="…" env="…" /> at the root. -->',
|
|
90
|
+
].join('\n');
|
|
91
|
+
case 'astro':
|
|
92
|
+
return [
|
|
93
|
+
'// npm i @cookiecrumbs/astro',
|
|
94
|
+
'// astro.config.mjs — the integration inserts the tag first in <head> on every route',
|
|
95
|
+
"import cookiecrumbs from '@cookiecrumbs/astro';",
|
|
96
|
+
'',
|
|
97
|
+
'export default defineConfig({',
|
|
98
|
+
` integrations: [cookiecrumbs({ site: '${i.siteKey}', env: '${i.env}', api: '${i.apiBase}' })],`,
|
|
99
|
+
'});',
|
|
100
|
+
'',
|
|
101
|
+
"<!-- Optional, in any page: <ConsentGate category=\"marketing\">, <CookieDeclaration />, <ManageCookiesLink /> from '@cookiecrumbs/astro/components' -->",
|
|
102
|
+
declaration,
|
|
103
|
+
].join('\n');
|
|
104
|
+
case 'nuxt':
|
|
105
|
+
return [
|
|
106
|
+
'// npm i @cookiecrumbs/nuxt',
|
|
107
|
+
'// nuxt.config.ts — the module injects the tag into <head> of every page',
|
|
108
|
+
'export default defineNuxtConfig({',
|
|
109
|
+
" modules: ['@cookiecrumbs/nuxt'],",
|
|
110
|
+
` cookiecrumbs: { site: '${i.siteKey}', env: '${i.env}', api: '${i.apiBase}' },`,
|
|
111
|
+
'});',
|
|
112
|
+
'',
|
|
113
|
+
'// useConsent() is auto-imported; <ConsentGate category="marketing"> and <CookieDeclaration /> are global components.',
|
|
114
|
+
'',
|
|
115
|
+
'<!-- Optional, in any page: -->',
|
|
116
|
+
declaration,
|
|
117
|
+
].join('\n');
|
|
118
|
+
case 'sveltekit':
|
|
119
|
+
return [
|
|
120
|
+
'// npm i @cookiecrumbs/sveltekit',
|
|
121
|
+
'// src/hooks.server.ts — inserts the tag into <head> and puts the visitor’s state on locals.consent',
|
|
122
|
+
"import { cookiecrumbsHandle } from '@cookiecrumbs/sveltekit';",
|
|
123
|
+
'',
|
|
124
|
+
`export const handle = cookiecrumbsHandle({ site: '${i.siteKey}', env: '${i.env}', api: '${i.apiBase}' });`,
|
|
125
|
+
'',
|
|
126
|
+
"// In a component: import { consent, granted } from '@cookiecrumbs/sveltekit' — both are stores.",
|
|
127
|
+
'',
|
|
128
|
+
'<!-- Optional, in any route: -->',
|
|
129
|
+
declaration,
|
|
130
|
+
].join('\n');
|
|
131
|
+
default:
|
|
132
|
+
return [
|
|
133
|
+
'<!-- Add to <head>, before any analytics or marketing tag -->',
|
|
134
|
+
tag(i),
|
|
135
|
+
'',
|
|
136
|
+
'<!-- Optional, anywhere in the page: -->',
|
|
137
|
+
declaration,
|
|
138
|
+
'<button data-cc-open>Cookie settings</button>',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, CommanderError } from 'commander';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { ctx, printJson, setCtx } from "./output.js";
|
|
7
|
+
import { cliVersion, normaliseApi, DEFAULT_API } from "./util.js";
|
|
8
|
+
import * as auth from "./commands/auth.js";
|
|
9
|
+
import * as init from "./commands/init.js";
|
|
10
|
+
import * as link from "./commands/link.js";
|
|
11
|
+
import * as push from "./commands/push.js";
|
|
12
|
+
import * as pull from "./commands/pull.js";
|
|
13
|
+
import * as diff from "./commands/diff.js";
|
|
14
|
+
import * as status from "./commands/status.js";
|
|
15
|
+
import * as open from "./commands/open.js";
|
|
16
|
+
import * as versions from "./commands/versions.js";
|
|
17
|
+
import * as scan from "./commands/scan.js";
|
|
18
|
+
import * as logs from "./commands/logs.js";
|
|
19
|
+
import * as exportAll from "./commands/export.js";
|
|
20
|
+
import * as declaration from "./commands/declaration.js";
|
|
21
|
+
import * as tokens from "./commands/tokens.js";
|
|
22
|
+
import * as alerts from "./commands/alerts.js";
|
|
23
|
+
import * as webhooks from "./commands/webhooks.js";
|
|
24
|
+
import * as usageCmd from "./commands/usage.js";
|
|
25
|
+
import * as templates from "./commands/templates.js";
|
|
26
|
+
import * as sites from "./commands/sites.js";
|
|
27
|
+
import * as services from "./commands/services.js";
|
|
28
|
+
import * as schedule from "./commands/schedule.js";
|
|
29
|
+
import * as issues from "./commands/issues.js";
|
|
30
|
+
import * as install from "./commands/install.js";
|
|
31
|
+
import * as domains from "./commands/domains.js";
|
|
32
|
+
const version = cliVersion();
|
|
33
|
+
export function buildProgram() {
|
|
34
|
+
const program = new Command('cookiecrumbs')
|
|
35
|
+
.description('CookieCrumbs: push and pull banner config, publish versions, run scans, manage sites, services, schedules, issues, domains and templates, export consent logs, watch alerts and webhooks')
|
|
36
|
+
.version(version, '-v, --version')
|
|
37
|
+
.option('--json', 'print a single JSON object to stdout and nothing else')
|
|
38
|
+
.option('--api <url>', 'API base URL (env COOKIECRUMBS_API)', process.env.COOKIECRUMBS_API || DEFAULT_API)
|
|
39
|
+
.option('--cwd <dir>', 'run as if started in this folder')
|
|
40
|
+
.showSuggestionAfterError(true)
|
|
41
|
+
.configureOutput({
|
|
42
|
+
writeErr: (s) => {
|
|
43
|
+
if (!ctx().json)
|
|
44
|
+
process.stderr.write(s);
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
.exitOverride();
|
|
48
|
+
program.hook('preAction', (thisCommand) => {
|
|
49
|
+
const o = thisCommand.opts();
|
|
50
|
+
setCtx({ json: Boolean(o.json), api: normaliseApi(o.api), cwd: o.cwd ? o.cwd : process.cwd() });
|
|
51
|
+
});
|
|
52
|
+
for (const m of [auth, init, link, push, pull, diff, status, open, versions, scan, schedule, logs, exportAll, declaration, tokens, alerts, webhooks, usageCmd, templates, sites, services, issues, install, domains])
|
|
53
|
+
m.register(program);
|
|
54
|
+
return program;
|
|
55
|
+
}
|
|
56
|
+
function report(err) {
|
|
57
|
+
if (err instanceof CommanderError) {
|
|
58
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version' || err.code === 'commander.help')
|
|
59
|
+
return 0;
|
|
60
|
+
if (ctx().json || process.argv.includes('--json'))
|
|
61
|
+
printJson({ ok: false, error: { code: 'usage', detail: err.message.trim(), exit: 2 } });
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
if (err instanceof CliError) {
|
|
65
|
+
if (ctx().json) {
|
|
66
|
+
printJson({ ok: false, error: err.toJSON() });
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
p.log.error(pc.red(err.detail));
|
|
70
|
+
const lint = (err.extra?.lint ?? null);
|
|
71
|
+
if (lint?.issues?.length)
|
|
72
|
+
p.log.message(lint.issues.map((i) => `${i.severity === 'error' ? pc.red('error') : pc.yellow('warn ')} ${i.code} ${pc.dim(i.path)} ${i.message}`).join('\n'));
|
|
73
|
+
if (err.status)
|
|
74
|
+
p.log.message(pc.dim(`HTTP ${err.status} · ${err.code}`));
|
|
75
|
+
}
|
|
76
|
+
return err.exit;
|
|
77
|
+
}
|
|
78
|
+
const e = err;
|
|
79
|
+
if (ctx().json)
|
|
80
|
+
printJson({ ok: false, error: { code: 'internal', detail: e?.message ?? String(err), exit: 1 } });
|
|
81
|
+
else {
|
|
82
|
+
p.log.error(pc.red(e?.message ?? String(err)));
|
|
83
|
+
if (process.env.DEBUG && e?.stack)
|
|
84
|
+
process.stderr.write(e.stack + '\n');
|
|
85
|
+
}
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
export async function main(argv = process.argv) {
|
|
89
|
+
const program = buildProgram();
|
|
90
|
+
try {
|
|
91
|
+
await program.parseAsync(argv);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return report(err);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
main().then((code) => {
|
|
99
|
+
process.exitCode = code;
|
|
100
|
+
});
|