@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,59 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { human, spinner } from "./output.js";
|
|
3
|
+
import { pollMs, sleep } from "./util.js";
|
|
4
|
+
const WORKER_HINT = 'Export jobs are processed by cc-worker; make sure a worker is running (npm run start -w @cookiecrumbs/cc-worker).';
|
|
5
|
+
/** Poll GET /v1/exports/:id every 3 s (default) until completed/failed/expired or the timeout. */
|
|
6
|
+
export async function waitForExport(api, id, opts = {}) {
|
|
7
|
+
const timeoutMs = opts.timeoutMs ?? 10 * 60 * 1000;
|
|
8
|
+
const started = Date.now();
|
|
9
|
+
const s = spinner(`${opts.label ?? 'Export'} ${id} queued — ${WORKER_HINT}`);
|
|
10
|
+
let polls = 0;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const job = await api.exportJob(id);
|
|
13
|
+
polls++;
|
|
14
|
+
if (job.status === 'completed') {
|
|
15
|
+
s.stop(`${opts.label ?? 'Export'} ${id} completed${job.record_count != null ? ` (${job.record_count} records)` : ''}`);
|
|
16
|
+
return job;
|
|
17
|
+
}
|
|
18
|
+
if (job.status === 'failed' || job.status === 'expired') {
|
|
19
|
+
s.fail(`${opts.label ?? 'Export'} ${id} ${job.status}${job.error ? `: ${job.error}` : ''}`);
|
|
20
|
+
throw new CliError(`export_${job.status}`, `Export job ${id} ${job.status}${job.error ? `: ${job.error}` : ''}.`, 1, { extra: { job } });
|
|
21
|
+
}
|
|
22
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
23
|
+
s.message(`${opts.label ?? 'Export'} ${id} ${job.status} (${elapsed}s) — ${job.status === 'queued' ? 'waiting for a worker' : 'running'}`);
|
|
24
|
+
if (Date.now() - started > timeoutMs) {
|
|
25
|
+
s.fail(`Gave up after ${Math.round(timeoutMs / 1000)}s; the job is still ${job.status}`);
|
|
26
|
+
throw new CliError('export_timeout', `Export job ${id} is still ${job.status} after ${Math.round(timeoutMs / 1000)}s. ${WORKER_HINT} Re-attach later with --job ${id}.`, 1, { extra: { job, polls } });
|
|
27
|
+
}
|
|
28
|
+
await sleep(pollMs(3000));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const DONE = ['completed', 'failed', 'cancelled'];
|
|
32
|
+
/** Poll GET /v1/scans/:id every 5 s (default) until it finishes or the timeout. */
|
|
33
|
+
export async function waitForScan(api, id, opts = {}) {
|
|
34
|
+
const timeoutMs = opts.timeoutMs ?? 30 * 60 * 1000;
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
const s = spinner(`Checking scan ${id}`);
|
|
37
|
+
for (;;) {
|
|
38
|
+
const scan = await api.scan(id);
|
|
39
|
+
if (DONE.includes(scan.status)) {
|
|
40
|
+
if (scan.status === 'completed')
|
|
41
|
+
s.stop(`Scan ${id} completed`);
|
|
42
|
+
else
|
|
43
|
+
s.fail(`Scan ${id} ${scan.status}${scan.error ? `: ${scan.error}` : ''}`);
|
|
44
|
+
return scan;
|
|
45
|
+
}
|
|
46
|
+
const pages = scan.pages_crawled != null ? ` · ${scan.pages_crawled}${scan.page_cap ? `/${scan.page_cap}` : ''} pages` : '';
|
|
47
|
+
const prog = scan.progress && typeof scan.progress === 'object' ? scan.progress : {};
|
|
48
|
+
const url = typeof prog.url === 'string' ? ` · ${prog.url}` : '';
|
|
49
|
+
s.message(`Scan ${id} ${scan.status}${pages}${url} (${Math.round((Date.now() - started) / 1000)}s)`);
|
|
50
|
+
if (Date.now() - started > timeoutMs) {
|
|
51
|
+
s.fail(`Gave up after ${Math.round(timeoutMs / 1000)}s; the scan is still ${scan.status}`);
|
|
52
|
+
throw new CliError('scan_timeout', `Scan ${id} is still ${scan.status} after ${Math.round(timeoutMs / 1000)}s. Re-attach with --scan-id ${id}.`, 1, { extra: { scan } });
|
|
53
|
+
}
|
|
54
|
+
await sleep(pollMs(5000));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function noteWorker() {
|
|
58
|
+
human.info(WORKER_HINT);
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Stable JSON: keys sorted bytewise, no whitespace. Unlike the hashed-document
|
|
4
|
+
* canonJson in @cookiecrumbs/config this allows numbers (BannerConfig carries
|
|
5
|
+
* radius, expiry_months, …), so it is only used for local change detection.
|
|
6
|
+
*/
|
|
7
|
+
export function stableJson(value) {
|
|
8
|
+
if (value === null || typeof value !== 'object')
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return '[' + value.map(stableJson).join(',') + ']';
|
|
12
|
+
const obj = value;
|
|
13
|
+
const keys = Object.keys(obj)
|
|
14
|
+
.filter((k) => obj[k] !== undefined)
|
|
15
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
16
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableJson(obj[k])).join(',') + '}';
|
|
17
|
+
}
|
|
18
|
+
export const configHash = (value) => createHash('sha256').update(stableJson(value)).digest('hex');
|
|
19
|
+
/**
|
|
20
|
+
* Three-way decision on hashes:
|
|
21
|
+
* - remote unchanged since the last pull (or identical to local) → keep local
|
|
22
|
+
* - local unchanged since the last pull → overwrite with remote
|
|
23
|
+
* - both changed → conflict (caller writes cookiecrumbs.config.remote.json, exit 2)
|
|
24
|
+
*/
|
|
25
|
+
export function decide({ base, local, remote }) {
|
|
26
|
+
if (local === remote)
|
|
27
|
+
return 'keep';
|
|
28
|
+
if (base !== null && remote === base)
|
|
29
|
+
return 'keep';
|
|
30
|
+
if (base !== null && local === base)
|
|
31
|
+
return 'overwrite';
|
|
32
|
+
return 'conflict';
|
|
33
|
+
}
|
|
34
|
+
export function mergeDecision(base, local, remote) {
|
|
35
|
+
const localHash = configHash(local);
|
|
36
|
+
const remoteHash = configHash(remote);
|
|
37
|
+
return { decision: decide({ base, local: localHash, remote: remoteHash }), localHash, remoteHash };
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
let current = { json: false, api: '', cwd: process.cwd() };
|
|
5
|
+
export const setCtx = (c) => (current = c);
|
|
6
|
+
export const ctx = () => current;
|
|
7
|
+
export const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
|
|
8
|
+
/** Print the single JSON object a `--json` run is allowed to emit. */
|
|
9
|
+
export function printJson(obj) {
|
|
10
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
11
|
+
}
|
|
12
|
+
// ---- human output (silent under --json) ---------------------------------
|
|
13
|
+
export const human = {
|
|
14
|
+
intro(text) {
|
|
15
|
+
if (!current.json)
|
|
16
|
+
p.intro(pc.bgCyan(pc.black(' cookiecrumbs ')) + ' ' + text);
|
|
17
|
+
},
|
|
18
|
+
outro(text) {
|
|
19
|
+
if (!current.json)
|
|
20
|
+
p.outro(text);
|
|
21
|
+
},
|
|
22
|
+
info(text) {
|
|
23
|
+
if (!current.json)
|
|
24
|
+
p.log.info(text);
|
|
25
|
+
},
|
|
26
|
+
step(text) {
|
|
27
|
+
if (!current.json)
|
|
28
|
+
p.log.step(text);
|
|
29
|
+
},
|
|
30
|
+
success(text) {
|
|
31
|
+
if (!current.json)
|
|
32
|
+
p.log.success(text);
|
|
33
|
+
},
|
|
34
|
+
warn(text) {
|
|
35
|
+
if (!current.json)
|
|
36
|
+
p.log.warn(text);
|
|
37
|
+
},
|
|
38
|
+
error(text) {
|
|
39
|
+
if (!current.json)
|
|
40
|
+
p.log.error(text);
|
|
41
|
+
},
|
|
42
|
+
message(text) {
|
|
43
|
+
if (!current.json)
|
|
44
|
+
p.log.message(text);
|
|
45
|
+
},
|
|
46
|
+
note(text, title) {
|
|
47
|
+
if (!current.json)
|
|
48
|
+
p.note(text, title);
|
|
49
|
+
},
|
|
50
|
+
/** Raw lines to stdout (e.g. a diff) — still suppressed under --json. */
|
|
51
|
+
print(text) {
|
|
52
|
+
if (!current.json)
|
|
53
|
+
process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
/** A clack spinner on a TTY, plain log lines otherwise, nothing under --json. */
|
|
57
|
+
export function spinner(start) {
|
|
58
|
+
if (current.json)
|
|
59
|
+
return { message: () => { }, stop: () => { }, fail: () => { } };
|
|
60
|
+
if (isInteractive()) {
|
|
61
|
+
const s = p.spinner();
|
|
62
|
+
s.start(start);
|
|
63
|
+
return {
|
|
64
|
+
message: (t) => s.message(t),
|
|
65
|
+
stop: (t) => s.stop(t ?? start),
|
|
66
|
+
fail: (t) => s.stop(pc.red(t), 1),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
process.stdout.write(`… ${start}\n`);
|
|
70
|
+
return {
|
|
71
|
+
message: (t) => process.stdout.write(`… ${t}\n`),
|
|
72
|
+
stop: (t) => process.stdout.write(`✓ ${t ?? start}\n`),
|
|
73
|
+
fail: (t) => process.stdout.write(`✗ ${t}\n`),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// ---- prompts (never under --json or on a non-TTY) ------------------------
|
|
77
|
+
function guard(flag) {
|
|
78
|
+
if (current.json || !isInteractive()) {
|
|
79
|
+
throw new CliError('non_interactive', `Not a terminal: pass ${flag} instead of answering a prompt.`, 2);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function cancelled(value) {
|
|
83
|
+
p.cancel('Cancelled.');
|
|
84
|
+
throw new CliError('cancelled', 'Cancelled.', 2, { extra: { value: String(value) } });
|
|
85
|
+
}
|
|
86
|
+
export async function askText(message, flag, opts = {}) {
|
|
87
|
+
guard(flag);
|
|
88
|
+
const v = await p.text({ message, placeholder: opts.placeholder, initialValue: opts.initial, validate: opts.validate });
|
|
89
|
+
if (p.isCancel(v))
|
|
90
|
+
cancelled(v);
|
|
91
|
+
return String(v);
|
|
92
|
+
}
|
|
93
|
+
export async function askSelect(message, flag, options) {
|
|
94
|
+
guard(flag);
|
|
95
|
+
const v = await p.select({ message, options: options });
|
|
96
|
+
if (p.isCancel(v))
|
|
97
|
+
cancelled(v);
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
export async function askConfirm(message, flag, initial = true) {
|
|
101
|
+
guard(flag);
|
|
102
|
+
const v = await p.confirm({ message, initialValue: initial });
|
|
103
|
+
if (p.isCancel(v))
|
|
104
|
+
cancelled(v);
|
|
105
|
+
return Boolean(v);
|
|
106
|
+
}
|
|
107
|
+
export const dim = pc.dim;
|
|
108
|
+
export const bold = pc.bold;
|
|
109
|
+
export const green = pc.green;
|
|
110
|
+
export const red = pc.red;
|
|
111
|
+
export const yellow = pc.yellow;
|
|
112
|
+
export const cyan = pc.cyan;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { createJiti } from 'jiti';
|
|
5
|
+
import { BannerConfigSchema } from "./configpkg.js";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
export const CONFIG_TS = 'cookiecrumbs.config.ts';
|
|
8
|
+
export const CONFIG_JSON = 'cookiecrumbs.config.json';
|
|
9
|
+
export const CONFIG_REMOTE = 'cookiecrumbs.config.remote.json';
|
|
10
|
+
export const TEXTS_DIR = join('cookiecrumbs', 'texts');
|
|
11
|
+
export const STATE_DIR = '.cookiecrumbs';
|
|
12
|
+
export const STATE_FILE = join(STATE_DIR, 'state.json');
|
|
13
|
+
export const PULLED_FILE = join(STATE_DIR, 'last-pulled.json');
|
|
14
|
+
// ---- project root -------------------------------------------------------
|
|
15
|
+
/** Walk up from cwd to the nearest folder holding a config file or .cookiecrumbs/; default cwd. */
|
|
16
|
+
export function findProjectRoot(cwd) {
|
|
17
|
+
let dir = resolve(cwd);
|
|
18
|
+
for (let i = 0; i < 20; i++) {
|
|
19
|
+
if (existsSync(join(dir, CONFIG_TS)) || existsSync(join(dir, CONFIG_JSON)) || existsSync(join(dir, STATE_FILE)))
|
|
20
|
+
return dir;
|
|
21
|
+
const parent = dirname(dir);
|
|
22
|
+
if (parent === dir)
|
|
23
|
+
break;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
return resolve(cwd);
|
|
27
|
+
}
|
|
28
|
+
export function configFile(root) {
|
|
29
|
+
if (existsSync(join(root, CONFIG_TS)))
|
|
30
|
+
return { path: join(root, CONFIG_TS), format: 'ts' };
|
|
31
|
+
if (existsSync(join(root, CONFIG_JSON)))
|
|
32
|
+
return { path: join(root, CONFIG_JSON), format: 'json' };
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// ---- state --------------------------------------------------------------
|
|
36
|
+
export function readState(root) {
|
|
37
|
+
const f = join(root, STATE_FILE);
|
|
38
|
+
if (!existsSync(f))
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(f, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function writeState(root, state) {
|
|
48
|
+
mkdirSync(join(root, STATE_DIR), { recursive: true });
|
|
49
|
+
writeFileSync(join(root, STATE_FILE), JSON.stringify(state, null, 2) + '\n');
|
|
50
|
+
}
|
|
51
|
+
export function requireState(root) {
|
|
52
|
+
const s = readState(root);
|
|
53
|
+
if (!s?.site_id)
|
|
54
|
+
throw new CliError('not_linked', `No ${STATE_FILE} here. Run \`cookiecrumbs init\` or \`cookiecrumbs link --site <id>\`.`, 2);
|
|
55
|
+
return s;
|
|
56
|
+
}
|
|
57
|
+
export function readPulled(root) {
|
|
58
|
+
const f = join(root, PULLED_FILE);
|
|
59
|
+
if (!existsSync(f))
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(f, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function writePulled(root, config) {
|
|
69
|
+
mkdirSync(join(root, STATE_DIR), { recursive: true });
|
|
70
|
+
writeFileSync(join(root, PULLED_FILE), JSON.stringify(config, null, 2) + '\n');
|
|
71
|
+
}
|
|
72
|
+
export function readTextsDir(root) {
|
|
73
|
+
const dir = join(root, TEXTS_DIR);
|
|
74
|
+
const out = {};
|
|
75
|
+
if (!existsSync(dir))
|
|
76
|
+
return out;
|
|
77
|
+
for (const f of readdirSync(dir)) {
|
|
78
|
+
if (!f.endsWith('.json'))
|
|
79
|
+
continue;
|
|
80
|
+
const lang = f.slice(0, -5);
|
|
81
|
+
try {
|
|
82
|
+
const raw = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
83
|
+
out[lang] = { texts: raw.texts ?? {}, category_texts: raw.category_texts ?? {} };
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
throw new CliError('texts_invalid', `${relative(root, join(dir, f))} is not valid JSON: ${e.message}`, 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
export function writeTextsDir(root, config) {
|
|
92
|
+
const dir = join(root, TEXTS_DIR);
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
const langs = new Set([...Object.keys(config.texts ?? {}), ...Object.keys(config.category_texts ?? {})]);
|
|
95
|
+
const written = [];
|
|
96
|
+
for (const lang of [...langs].sort()) {
|
|
97
|
+
const doc = { texts: config.texts?.[lang] ?? {}, category_texts: config.category_texts?.[lang] ?? {} };
|
|
98
|
+
const file = join(dir, `${lang}.json`);
|
|
99
|
+
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
|
|
100
|
+
written.push(file);
|
|
101
|
+
}
|
|
102
|
+
// drop files for languages that no longer exist
|
|
103
|
+
for (const f of readdirSync(dir))
|
|
104
|
+
if (f.endsWith('.json') && !langs.has(f.slice(0, -5)))
|
|
105
|
+
rmSync(join(dir, f));
|
|
106
|
+
return written;
|
|
107
|
+
}
|
|
108
|
+
/** Merge cookiecrumbs/texts/<lang>.json over the config file's own texts (the file may omit them). */
|
|
109
|
+
export function mergeTexts(config, texts) {
|
|
110
|
+
const out = { ...config };
|
|
111
|
+
const t = { ...(config.texts ?? {}) };
|
|
112
|
+
const ct = { ...(config.category_texts ?? {}) };
|
|
113
|
+
for (const [lang, doc] of Object.entries(texts)) {
|
|
114
|
+
if (Object.keys(doc.texts).length)
|
|
115
|
+
t[lang] = { ...(t[lang] ?? {}), ...doc.texts };
|
|
116
|
+
if (Object.keys(doc.category_texts).length)
|
|
117
|
+
ct[lang] = { ...(ct[lang] ?? {}), ...doc.category_texts };
|
|
118
|
+
}
|
|
119
|
+
out.texts = t;
|
|
120
|
+
out.category_texts = ct;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
// ---- loader -------------------------------------------------------------
|
|
124
|
+
/** Path of the compiled (or source) @cookiecrumbs/config entry shipped with the CLI, for the jiti alias. */
|
|
125
|
+
export function bundledConfigPackage() {
|
|
126
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
127
|
+
for (const rel of ['../../config/src/index.js', '../../config/src/index.ts']) {
|
|
128
|
+
const p = resolve(here, rel);
|
|
129
|
+
if (existsSync(p))
|
|
130
|
+
return p;
|
|
131
|
+
}
|
|
132
|
+
return '@cookiecrumbs/config';
|
|
133
|
+
}
|
|
134
|
+
/** Load cookiecrumbs.config.ts|json (jiti for TS, JSON fallback) and merge the texts folder. Validates against the schema. */
|
|
135
|
+
export async function loadLocalConfig(root) {
|
|
136
|
+
const file = configFile(root);
|
|
137
|
+
if (!file)
|
|
138
|
+
throw new CliError('no_config', `No ${CONFIG_TS} or ${CONFIG_JSON} in ${root}. Run \`cookiecrumbs init\`.`, 2);
|
|
139
|
+
let raw;
|
|
140
|
+
if (file.format === 'json') {
|
|
141
|
+
try {
|
|
142
|
+
raw = JSON.parse(readFileSync(file.path, 'utf8'));
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
throw new CliError('config_invalid', `${CONFIG_JSON} is not valid JSON: ${e.message}`, 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
raw = await loadTs(file.path);
|
|
150
|
+
}
|
|
151
|
+
if (!raw || typeof raw !== 'object')
|
|
152
|
+
throw new CliError('config_invalid', `${relative(root, file.path)} does not export a config object.`, 1);
|
|
153
|
+
const texts = readTextsDir(root);
|
|
154
|
+
const merged = mergeTexts(raw, texts);
|
|
155
|
+
const parsed = BannerConfigSchema.safeParse(merged);
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
|
158
|
+
throw new CliError('schema', `Config does not match the BannerConfig schema:\n ${issues.join('\n ')}`, 1, { extra: { issues: parsed.error.issues } });
|
|
159
|
+
}
|
|
160
|
+
return { config: parsed.data, raw: merged, file: file.path, format: file.format, langs: Object.keys(texts).sort() };
|
|
161
|
+
}
|
|
162
|
+
async function loadTs(path) {
|
|
163
|
+
const jiti = createJiti(pathToFileURL(path).href, {
|
|
164
|
+
interopDefault: true,
|
|
165
|
+
moduleCache: false,
|
|
166
|
+
fsCache: false,
|
|
167
|
+
alias: { '@cookiecrumbs/config': bundledConfigPackage() },
|
|
168
|
+
});
|
|
169
|
+
let mod;
|
|
170
|
+
try {
|
|
171
|
+
mod = await jiti.import(path, { default: true });
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
throw new CliError('config_invalid', `Could not load ${path}: ${e.message}`, 1);
|
|
175
|
+
}
|
|
176
|
+
if (typeof mod === 'function')
|
|
177
|
+
mod = await mod();
|
|
178
|
+
return mod;
|
|
179
|
+
}
|
|
180
|
+
// ---- writer -------------------------------------------------------------
|
|
181
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
182
|
+
/** Readable TypeScript object literal (2-space indent, unquoted identifier keys, JSON-escaped strings). */
|
|
183
|
+
export function toTsLiteral(value, indent = 0) {
|
|
184
|
+
const pad = ' '.repeat(indent);
|
|
185
|
+
const inner = ' '.repeat(indent + 1);
|
|
186
|
+
if (value === null || value === undefined)
|
|
187
|
+
return 'null';
|
|
188
|
+
if (typeof value === 'string')
|
|
189
|
+
return JSON.stringify(value);
|
|
190
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
191
|
+
return String(value);
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
if (value.length === 0)
|
|
194
|
+
return '[]';
|
|
195
|
+
const simple = value.every((v) => typeof v !== 'object' || v === null);
|
|
196
|
+
if (simple)
|
|
197
|
+
return '[' + value.map((v) => toTsLiteral(v, indent + 1)).join(', ') + ']';
|
|
198
|
+
return '[\n' + value.map((v) => inner + toTsLiteral(v, indent + 1)).join(',\n') + ',\n' + pad + ']';
|
|
199
|
+
}
|
|
200
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
201
|
+
if (entries.length === 0)
|
|
202
|
+
return '{}';
|
|
203
|
+
return ('{\n' +
|
|
204
|
+
entries.map(([k, v]) => `${inner}${IDENT.test(k) ? k : JSON.stringify(k)}: ${toTsLiteral(v, indent + 1)}`).join(',\n') +
|
|
205
|
+
',\n' +
|
|
206
|
+
pad +
|
|
207
|
+
'}');
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Writes the project config. With splitTexts (default) texts/category_texts go to
|
|
211
|
+
* cookiecrumbs/texts/<lang>.json and the config file keeps empty placeholders
|
|
212
|
+
* (the schema still requires the keys; the loader merges the folder back in).
|
|
213
|
+
*/
|
|
214
|
+
export function writeLocalConfig(root, config, opts = {}) {
|
|
215
|
+
const format = opts.format ?? 'ts';
|
|
216
|
+
const split = opts.splitTexts ?? true;
|
|
217
|
+
const written = [];
|
|
218
|
+
const body = { ...config };
|
|
219
|
+
if (split) {
|
|
220
|
+
written.push(...writeTextsDir(root, config));
|
|
221
|
+
body.texts = {};
|
|
222
|
+
body.category_texts = {};
|
|
223
|
+
}
|
|
224
|
+
if (format === 'json') {
|
|
225
|
+
const file = join(root, CONFIG_JSON);
|
|
226
|
+
writeFileSync(file, JSON.stringify(body, null, 2) + '\n');
|
|
227
|
+
written.unshift(file);
|
|
228
|
+
if (existsSync(join(root, CONFIG_TS)))
|
|
229
|
+
rmSync(join(root, CONFIG_TS));
|
|
230
|
+
return written;
|
|
231
|
+
}
|
|
232
|
+
const ordered = orderConfig(body);
|
|
233
|
+
const lines = [
|
|
234
|
+
"import { defineConfig } from '@cookiecrumbs/config';",
|
|
235
|
+
'',
|
|
236
|
+
'// CookieCrumbs banner configuration. Edit, then `cookiecrumbs push`.',
|
|
237
|
+
split ? '// Texts per language live in cookiecrumbs/texts/<lang>.json and are merged on push.' : '// Texts are inline; `cookiecrumbs pull` rewrites this file.',
|
|
238
|
+
'',
|
|
239
|
+
`export default defineConfig(${toTsLiteral(ordered)});`,
|
|
240
|
+
'',
|
|
241
|
+
];
|
|
242
|
+
const file = join(root, CONFIG_TS);
|
|
243
|
+
writeFileSync(file, lines.join('\n'));
|
|
244
|
+
written.unshift(file);
|
|
245
|
+
if (existsSync(join(root, CONFIG_JSON)))
|
|
246
|
+
rmSync(join(root, CONFIG_JSON));
|
|
247
|
+
return written;
|
|
248
|
+
}
|
|
249
|
+
const KEY_ORDER = ['schema_version', 'layout', 'theme', 'default_lang', 'languages', 'texts', 'category_texts', 'categories', 'block', 'regions', 'consent_mode', 'behaviour'];
|
|
250
|
+
function orderConfig(c) {
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const k of KEY_ORDER)
|
|
253
|
+
if (k in c)
|
|
254
|
+
out[k] = c[k];
|
|
255
|
+
for (const k of Object.keys(c))
|
|
256
|
+
if (!(k in out))
|
|
257
|
+
out[k] = c[k];
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
export function writeRemoteConflict(root, config) {
|
|
261
|
+
const file = join(root, CONFIG_REMOTE);
|
|
262
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
263
|
+
return file;
|
|
264
|
+
}
|
|
265
|
+
export function clearRemoteConflict(root) {
|
|
266
|
+
const file = join(root, CONFIG_REMOTE);
|
|
267
|
+
if (existsSync(file))
|
|
268
|
+
rmSync(file);
|
|
269
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
export const DEFAULT_API = 'https://api.cookiecrumbs.eu';
|
|
7
|
+
export const DEFAULT_APP = 'https://app.cookiecrumbs.eu';
|
|
8
|
+
/** Version from this package's package.json (works from src/ via tsx and from dist/). */
|
|
9
|
+
export function cliVersion() {
|
|
10
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
for (let i = 0; i < 6; i++) {
|
|
12
|
+
const candidate = join(dir, 'package.json');
|
|
13
|
+
if (existsSync(candidate)) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
16
|
+
if (pkg.name === 'cookiecrumbs' && pkg.version)
|
|
17
|
+
return pkg.version;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* keep walking */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const parent = dirname(dir);
|
|
24
|
+
if (parent === dir)
|
|
25
|
+
break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return createRequire(import.meta.url)('cookiecrumbs/package.json').version;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return '0.0.0';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
/** Poll intervals can be shortened in tests through COOKIECRUMBS_POLL_MS. */
|
|
37
|
+
export function pollMs(defaultMs) {
|
|
38
|
+
const v = Number(process.env.COOKIECRUMBS_POLL_MS);
|
|
39
|
+
return Number.isFinite(v) && v > 0 ? v : defaultMs;
|
|
40
|
+
}
|
|
41
|
+
/** Normalise an API base: strip trailing slashes and a trailing /v1 (paths add it). */
|
|
42
|
+
export function normaliseApi(base) {
|
|
43
|
+
let b = base.trim().replace(/\/+$/, '');
|
|
44
|
+
if (b.endsWith('/v1'))
|
|
45
|
+
b = b.slice(0, -3);
|
|
46
|
+
return b;
|
|
47
|
+
}
|
|
48
|
+
/** The base the banner runtime talks to (consent/config/declaration functions). */
|
|
49
|
+
export function runtimeApiBase(api) {
|
|
50
|
+
const b = normaliseApi(api);
|
|
51
|
+
return b.endsWith('/api') ? b.slice(0, -4) : b;
|
|
52
|
+
}
|
|
53
|
+
export function appUrl() {
|
|
54
|
+
return (process.env.COOKIECRUMBS_APP ?? DEFAULT_APP).replace(/\/+$/, '');
|
|
55
|
+
}
|
|
56
|
+
/** Open a URL in the user's browser; never throws (the URL is always printed too). */
|
|
57
|
+
export function openBrowser(url) {
|
|
58
|
+
try {
|
|
59
|
+
const platform = process.platform;
|
|
60
|
+
const child = platform === 'win32'
|
|
61
|
+
? spawn('cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
62
|
+
: platform === 'darwin'
|
|
63
|
+
? spawn('open', [url], { detached: true, stdio: 'ignore' })
|
|
64
|
+
: spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
65
|
+
child.on('error', () => { });
|
|
66
|
+
child.unref();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** `2026-08-01` or any ISO string → ISO timestamp (dates are taken as 00:00 UTC). */
|
|
74
|
+
export function toIso(input, fallback) {
|
|
75
|
+
if (!input)
|
|
76
|
+
return fallback().toISOString();
|
|
77
|
+
const s = input.trim();
|
|
78
|
+
const d = /^\d{4}-\d{2}-\d{2}$/.test(s) ? new Date(`${s}T00:00:00.000Z`) : new Date(s);
|
|
79
|
+
if (Number.isNaN(d.getTime()))
|
|
80
|
+
throw new Error(`Not a date: ${input}`);
|
|
81
|
+
return d.toISOString();
|
|
82
|
+
}
|
|
83
|
+
export const firstOfMonth = () => {
|
|
84
|
+
const n = new Date();
|
|
85
|
+
return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), 1));
|
|
86
|
+
};
|
|
87
|
+
export function formatDate(iso) {
|
|
88
|
+
if (!iso)
|
|
89
|
+
return '—';
|
|
90
|
+
const d = new Date(iso);
|
|
91
|
+
if (Number.isNaN(d.getTime()))
|
|
92
|
+
return String(iso);
|
|
93
|
+
return d.toISOString().replace('T', ' ').slice(0, 16) + 'Z';
|
|
94
|
+
}
|
|
95
|
+
/** Small fixed-width table for human output. */
|
|
96
|
+
export function table(rows, header) {
|
|
97
|
+
const all = header ? [header, ...rows] : rows;
|
|
98
|
+
const widths = [];
|
|
99
|
+
for (const r of all)
|
|
100
|
+
r.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, c.length)));
|
|
101
|
+
const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
102
|
+
const out = all.map(line);
|
|
103
|
+
if (header)
|
|
104
|
+
out.splice(1, 0, widths.map((w) => '-'.repeat(w)).join(' '));
|
|
105
|
+
return out.join('\n');
|
|
106
|
+
}
|
|
107
|
+
export function parseCsvList(v) {
|
|
108
|
+
if (!v)
|
|
109
|
+
return undefined;
|
|
110
|
+
return v
|
|
111
|
+
.split(',')
|
|
112
|
+
.map((s) => s.trim())
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
export function parseIntOpt(name) {
|
|
116
|
+
return (v) => {
|
|
117
|
+
const n = Number.parseInt(v, 10);
|
|
118
|
+
if (!Number.isFinite(n) || n < 0)
|
|
119
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
120
|
+
return n;
|
|
121
|
+
};
|
|
122
|
+
}
|