@minhspark/codex-mcp-bridge 1.16.0 → 1.17.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/CHANGELOG.md +30 -0
- package/README.md +225 -565
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +173 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +37 -0
- package/scripts/render-repo-analytics.mjs +50 -0
- package/scripts/usage-summary.sql +19 -0
- package/src/claude-bridge-launcher.mjs +2 -1
- package/src/claude-bridge.mjs +102 -16
- package/src/claude-session-router.mjs +16 -0
- package/src/codex-bridge-launcher.mjs +2 -1
- package/src/codex-native-response.mjs +333 -0
- package/src/codex-sender-context.mjs +5 -4
- package/src/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -23
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +114 -20
- package/src/reply-forwarder.mjs +10 -3
- package/src/security-policy.mjs +4 -0
- package/src/telemetry-config.mjs +2 -0
- package/src/telemetry.mjs +149 -0
- package/src/thread-delivery.mjs +89 -11
- package/src/turn.mjs +35 -3
- package/src/vscode-bridge.mjs +162 -0
- package/src/vscode-delivery.mjs +19 -0
- package/src/vscode-ipc.mjs +80 -0
- package/src/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
+
"analytics": "node scripts/collect-repo-analytics.mjs",
|
|
45
|
+
"analytics:publish": "node scripts/publish-repo-analytics.mjs",
|
|
44
46
|
"start": "node src/codex-bridge-launcher.mjs",
|
|
45
47
|
"claude-bridge": "node src/claude-bridge-launcher.mjs",
|
|
46
48
|
"app-server": "codex app-server --listen ws://127.0.0.1:8791",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { mkdir, readFile, open, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
const defaultRepo = 'buidangminh23/codex-mcp-bridge';
|
|
11
|
+
const defaultPackage = '@minhspark/codex-mcp-bridge';
|
|
12
|
+
|
|
13
|
+
export function defaultOutput(platform = process.platform, env = process.env, home = homedir()) {
|
|
14
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'codex-mcp-bridge', 'analytics');
|
|
15
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'codex-mcp-bridge', 'analytics');
|
|
16
|
+
return path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'codex-mcp-bridge', 'analytics');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseArgs(args) {
|
|
20
|
+
const options = { repo: defaultRepo, package: defaultPackage, output: defaultOutput() };
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === '--help' || args[i] === '-h') return { help: true };
|
|
23
|
+
const name = args[i].slice(2);
|
|
24
|
+
if (!['--repo', '--package', '--output'].includes(args[i]) || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error('Expected --repo owner/name, --package name, or --output directory.');
|
|
25
|
+
options[name] = args[++i];
|
|
26
|
+
}
|
|
27
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(options.repo)) throw new Error('Invalid repository: expected owner/name.');
|
|
28
|
+
if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(options.package)) throw new Error('Invalid npm package name.');
|
|
29
|
+
options.output = path.resolve(options.output);
|
|
30
|
+
return options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function count(value) {
|
|
34
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid count.');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function day(value) {
|
|
39
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value) || !Number.isFinite(Date.parse(value))) throw new Error('Invalid date.');
|
|
40
|
+
return value.slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function traffic(data, key) {
|
|
44
|
+
if (!Array.isArray(data[key])) throw new Error('Missing daily traffic.');
|
|
45
|
+
return { count: count(data.count), uniques: count(data.uniques), [key]: data[key].map(row => ({ timestamp: `${day(row.timestamp)}T00:00:00Z`, count: count(row.count), uniques: count(row.uniques) })) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function httpStatus(error) {
|
|
49
|
+
const match = /\(HTTP (\d{3})\)/.exec(`${error?.stderr ?? ''}\n${error?.message ?? ''}`);
|
|
50
|
+
return match ? Number(match[1]) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function github(endpoint, paginate = false) {
|
|
54
|
+
const args = ['api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', endpoint];
|
|
55
|
+
if (paginate) args.push('--paginate', '--slurp');
|
|
56
|
+
const { stdout } = await execute('gh', args, { timeout: 60000, maxBuffer: 32 * 1024 * 1024, windowsHide: true });
|
|
57
|
+
return JSON.parse(stdout);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function collectSources(options, { gh = github, fetcher = fetch, now = new Date() } = {}) {
|
|
61
|
+
const snapshot = { collectedAt: now.toISOString(), errors: [] };
|
|
62
|
+
const jobs = {
|
|
63
|
+
views: async () => traffic(await gh(`repos/${options.repo}/traffic/views`), 'views'),
|
|
64
|
+
clones: async () => traffic(await gh(`repos/${options.repo}/traffic/clones`), 'clones'),
|
|
65
|
+
repository: async () => {
|
|
66
|
+
const data = await gh(`repos/${options.repo}`);
|
|
67
|
+
return { stars: count(data.stargazers_count), forks: count(data.forks_count), subscribers: count(data.subscribers_count) };
|
|
68
|
+
},
|
|
69
|
+
releases: async () => {
|
|
70
|
+
const pages = await gh(`repos/${options.repo}/releases?per_page=100`, true);
|
|
71
|
+
if (!Array.isArray(pages)) throw new Error('Invalid releases.');
|
|
72
|
+
return pages.flat().filter(release => !release.draft).map(release => ({
|
|
73
|
+
id: count(release.id), tag: String(release.tag_name),
|
|
74
|
+
assets: release.assets.map(asset => ({ id: count(asset.id), name: String(asset.name), downloads: count(asset.download_count) })),
|
|
75
|
+
}));
|
|
76
|
+
},
|
|
77
|
+
npm: async () => {
|
|
78
|
+
const response = await fetcher(`https://api.npmjs.org/downloads/range/last-month/${encodeURIComponent(options.package)}`, { signal: AbortSignal.timeout(30000) });
|
|
79
|
+
if (!response.ok) throw new Error('npm request failed.');
|
|
80
|
+
const data = await response.json();
|
|
81
|
+
if (data.package !== options.package || !Array.isArray(data.downloads)) throw new Error('Invalid npm response.');
|
|
82
|
+
return { start: day(data.start), end: day(data.end), downloads: data.downloads.map(row => ({ day: day(row.day), downloads: count(row.downloads) })) };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
await Promise.all(Object.entries(jobs).map(async ([source, job]) => {
|
|
86
|
+
try { snapshot[source] = await job(); }
|
|
87
|
+
catch (error) {
|
|
88
|
+
const entry = { source, message: source === 'npm' ? 'Request failed or response invalid. Check npm package, network, and npm API availability.' : 'Request failed or response invalid. Check gh authentication, repository permissions, network, and GitHub API limits.' };
|
|
89
|
+
const status = httpStatus(error);
|
|
90
|
+
if (status) entry.status = status;
|
|
91
|
+
snapshot.errors.push(entry);
|
|
92
|
+
}
|
|
93
|
+
}));
|
|
94
|
+
snapshot.errors.sort((a, b) => a.source.localeCompare(b.source));
|
|
95
|
+
return snapshot;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mergeHistory(previous, options, snapshot) {
|
|
99
|
+
if (previous && (previous.schemaVersion !== 1 || previous.repo !== options.repo || previous.package !== options.package || !Array.isArray(previous.snapshots) || !previous.daily?.views || !previous.daily?.clones || !previous.daily?.npm)) throw new Error('History schema or repository/package mismatch. Use a separate output directory.');
|
|
100
|
+
const history = previous ? structuredClone(previous) : { schemaVersion: 1, repo: options.repo, package: options.package, snapshots: [], daily: { views: {}, clones: {}, npm: {} } };
|
|
101
|
+
const collectionDay = day(snapshot.collectedAt);
|
|
102
|
+
const index = history.snapshots.findIndex(row => day(row.collectedAt) === collectionDay);
|
|
103
|
+
const prior = index < 0 ? {} : history.snapshots[index];
|
|
104
|
+
const merged = { ...prior, ...snapshot, sourceCollectedAt: { ...prior.sourceCollectedAt } };
|
|
105
|
+
for (const source of ['views', 'clones', 'npm', 'repository', 'releases']) {
|
|
106
|
+
if (Object.hasOwn(snapshot, source)) merged.sourceCollectedAt[source] = snapshot.collectedAt;
|
|
107
|
+
}
|
|
108
|
+
if (index < 0) history.snapshots.push(merged);
|
|
109
|
+
else history.snapshots[index] = merged;
|
|
110
|
+
history.snapshots.sort((a, b) => a.collectedAt.localeCompare(b.collectedAt));
|
|
111
|
+
history.updatedAt = snapshot.collectedAt;
|
|
112
|
+
for (const source of ['views', 'clones']) {
|
|
113
|
+
for (const row of snapshot[source]?.[source] || []) history.daily[source][day(row.timestamp)] = { count: row.count, uniques: row.uniques };
|
|
114
|
+
}
|
|
115
|
+
for (const row of snapshot.npm?.downloads || []) history.daily.npm[row.day] = { downloads: row.downloads };
|
|
116
|
+
return history;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function atomicWrite(filename, contents) {
|
|
120
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
121
|
+
let file;
|
|
122
|
+
try {
|
|
123
|
+
file = await open(temporary, 'wx', 0o600);
|
|
124
|
+
await file.writeFile(contents, 'utf8');
|
|
125
|
+
await file.sync();
|
|
126
|
+
await file.close();
|
|
127
|
+
file = null;
|
|
128
|
+
await rename(temporary, filename);
|
|
129
|
+
} finally {
|
|
130
|
+
await file?.close();
|
|
131
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runCollection(options, dependencies = {}) {
|
|
136
|
+
await mkdir(options.output, { recursive: true, mode: 0o700 });
|
|
137
|
+
const lockPath = path.join(options.output, '.collection.lock');
|
|
138
|
+
let lock;
|
|
139
|
+
try { lock = await open(lockPath, 'wx', 0o600); }
|
|
140
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('Collection lock exists. Wait for the active collector; if it crashed, remove .collection.lock only after confirming no collector is running.'); throw error; }
|
|
141
|
+
try {
|
|
142
|
+
await lock.writeFile(String(process.pid));
|
|
143
|
+
const historyPath = path.join(options.output, 'history.json');
|
|
144
|
+
let previous;
|
|
145
|
+
try { previous = JSON.parse(await readFile(historyPath, 'utf8')); }
|
|
146
|
+
catch (error) { if (error.code !== 'ENOENT') throw new Error('Cannot read existing history; preserve it and repair it before collecting.'); }
|
|
147
|
+
const snapshot = await collectSources(options, dependencies);
|
|
148
|
+
const history = mergeHistory(previous, options, snapshot);
|
|
149
|
+
await atomicWrite(historyPath, `${JSON.stringify(history, null, 2)}\n`);
|
|
150
|
+
const render = dependencies.render || (await import('./render-repo-analytics.mjs')).renderAnalytics;
|
|
151
|
+
await atomicWrite(path.join(options.output, 'index.html'), render(history));
|
|
152
|
+
return { history, errors: snapshot.errors, output: options.output };
|
|
153
|
+
} finally {
|
|
154
|
+
await lock.close();
|
|
155
|
+
await unlink(lockPath);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
160
|
+
try {
|
|
161
|
+
const options = parseArgs(process.argv.slice(2));
|
|
162
|
+
if (options.help) console.log('Usage: node scripts/collect-repo-analytics.mjs [--repo owner/name] [--package name] [--output directory]\nRequires authenticated GitHub CLI (gh). Saves aggregate analytics locally; partial source failure exits 1.');
|
|
163
|
+
else {
|
|
164
|
+
const result = await runCollection(options);
|
|
165
|
+
console.log(`Analytics saved: ${result.output}`);
|
|
166
|
+
for (const error of result.errors) console.error(`${error.source}: ${error.message}`);
|
|
167
|
+
if (result.errors.length) process.exitCode = 1;
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error(`Analytics collection failed: ${error.message}`);
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.
|
|
25
|
+
const VERSION = "1.17.0";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { defaultOutput } from './collect-repo-analytics.mjs';
|
|
6
|
+
|
|
7
|
+
const escapeXml = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
|
8
|
+
const number = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
9
|
+
const date = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z)?$/.test(value) && Number.isFinite(Date.parse(value)) ? value : null;
|
|
10
|
+
const numericFields = (value, keys) => Object.fromEntries(keys.map(key => [key, number(value?.[key])]));
|
|
11
|
+
const format = value => value == null ? 'Unavailable' : value.toLocaleString('en-US');
|
|
12
|
+
|
|
13
|
+
function sanitizeSnapshot(snapshot) {
|
|
14
|
+
const output = { collectedAt: date(snapshot.collectedAt) };
|
|
15
|
+
for (const source of ['views', 'clones']) {
|
|
16
|
+
if (snapshot[source]) output[source] = { ...numericFields(snapshot[source], ['count', 'uniques']), [source]: (snapshot[source][source] || []).map(row => ({ timestamp: date(row.timestamp), ...numericFields(row, ['count', 'uniques']) })) };
|
|
17
|
+
}
|
|
18
|
+
if (snapshot.repository) output.repository = numericFields(snapshot.repository, ['stars', 'forks', 'subscribers']);
|
|
19
|
+
if (snapshot.npm) output.npm = { start: date(snapshot.npm.start), end: date(snapshot.npm.end), downloads: (snapshot.npm.downloads || []).map(row => ({ day: date(row.day), downloads: number(row.downloads) })) };
|
|
20
|
+
if (snapshot.releases) output.releases = snapshot.releases.map(release => ({ tag: String(release.tag ?? '').slice(0, 120), assets: (release.assets || []).map(asset => ({ name: String(asset.name ?? '').slice(0, 180), downloads: number(asset.downloads) })) }));
|
|
21
|
+
output.sourceCollectedAt = Object.fromEntries(['views', 'clones', 'npm', 'repository', 'releases'].filter(source => output[source]).map(source => [source, date(snapshot.sourceCollectedAt?.[source] || snapshot.collectedAt)]));
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sanitizeAnalytics(history, usage) {
|
|
26
|
+
if (history.schemaVersion !== 1 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(history.repo) || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(history.package) || !Array.isArray(history.snapshots)) throw new Error('Invalid analytics history.');
|
|
27
|
+
const output = { schemaVersion: 1, repo: history.repo, package: history.package, updatedAt: date(history.updatedAt), snapshots: history.snapshots.map(sanitizeSnapshot), daily: {} };
|
|
28
|
+
for (const source of ['views', 'clones', 'npm']) output.daily[source] = Object.fromEntries(Object.entries(history.daily?.[source] || {}).filter(([day]) => /^\d{4}-\d{2}-\d{2}$/.test(day) && date(day)).map(([day, row]) => [day, numericFields(row, source === 'npm' ? ['downloads'] : ['count', 'uniques'])]));
|
|
29
|
+
if (usage) output.usage = {
|
|
30
|
+
collectedAt: date(usage.collectedAt), active: numericFields(usage.active, ['day', 'week', 'month']),
|
|
31
|
+
daily: (usage.daily || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.day) && date(row.day)).map(row => ({ day: row.day, installations: number(row.installations) })),
|
|
32
|
+
platforms: (usage.platforms || []).filter(row => ['windows', 'macos', 'linux'].includes(row.platform)).map(row => ({ platform: row.platform, installations: number(row.installations) })),
|
|
33
|
+
versions: (usage.versions || []).filter(row => /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(row.version)).map(row => ({ version: row.version, installations: number(row.installations) })),
|
|
34
|
+
};
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderPublicDashboard(data) {
|
|
39
|
+
const latest = source => [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row[source]);
|
|
40
|
+
const views = latest('views')?.views;
|
|
41
|
+
const clones = latest('clones')?.clones;
|
|
42
|
+
const repository = latest('repository')?.repository;
|
|
43
|
+
const npm = latest('npm')?.npm;
|
|
44
|
+
const releases = latest('releases')?.releases;
|
|
45
|
+
const assets = releases?.flatMap(release => release.assets.map(asset => ({ ...asset, tag: release.tag }))) || [];
|
|
46
|
+
const sum = rows => rows?.every(row => row.downloads != null) ? rows.reduce((total, row) => total + row.downloads, 0) : null;
|
|
47
|
+
const window = (source, rows) => `${rows?.[0]?.timestamp?.slice(0, 10) || '?'} to ${rows?.at(-1)?.timestamp?.slice(0, 10) || '?'} UTC`;
|
|
48
|
+
const cards = [
|
|
49
|
+
['GitHub views', views?.count, window('views', views?.views)], ['Unique visitors', views?.uniques, 'Distinct within GitHub window'], ['GitHub clones', clones?.count, window('clones', clones?.clones)],
|
|
50
|
+
['Unique cloners', clones?.uniques, 'Distinct within GitHub window'], ['npm downloads', sum(npm?.downloads), npm ? `${npm.start} to ${npm.end} UTC` : 'Source unavailable'], ['Stars', repository?.stars, 'Current repository total'],
|
|
51
|
+
['Forks', repository?.forks, 'Current repository total'], ['Subscribers', repository?.subscribers, 'Current repository total'], ['Release downloads', releases ? sum(assets) : null, 'Cumulative asset downloads'],
|
|
52
|
+
];
|
|
53
|
+
if (data.usage) cards.push(['Active installations / day', data.usage.active.day, 'Consenting installations only'], ['Active installations / 7 days', data.usage.active.week, 'Distinct across the whole period'], ['Active installations / 30 days', data.usage.active.month, 'Distinct across the whole period']);
|
|
54
|
+
const details = [
|
|
55
|
+
['Release assets', ...assets.map(asset => `${asset.tag} / ${asset.name}: ${format(asset.downloads)}`)],
|
|
56
|
+
['Operating systems / 30 days', ...(data.usage?.platforms || []).map(row => `${row.platform}: ${format(row.installations)}`)],
|
|
57
|
+
['Versions / 30 days', ...(data.usage?.versions || []).map(row => `${row.version}: ${format(row.installations)}`)],
|
|
58
|
+
['Source freshness (UTC)', ...['views', 'clones', 'npm', 'repository', 'releases'].map(source => `${source}: ${latest(source)?.sourceCollectedAt?.[source] || 'Unavailable'}`), ...(data.usage ? [`usage: ${data.usage.collectedAt || 'Unavailable'}`] : [])],
|
|
59
|
+
];
|
|
60
|
+
const wrap = (value, maximum) => {
|
|
61
|
+
const lines = [];
|
|
62
|
+
let remaining = String(value);
|
|
63
|
+
while (remaining.length > maximum) {
|
|
64
|
+
const space = remaining.lastIndexOf(' ', maximum);
|
|
65
|
+
const cut = space > maximum / 2 ? space : maximum;
|
|
66
|
+
lines.push(remaining.slice(0, cut));
|
|
67
|
+
remaining = remaining.slice(cut).trimStart();
|
|
68
|
+
}
|
|
69
|
+
return [...lines, remaining];
|
|
70
|
+
};
|
|
71
|
+
const cardRows = Math.ceil(cards.length / 2);
|
|
72
|
+
const text = (value, x, yy, size = 20, color = '#acbcd0') => `<text x="${x}" y="${yy}" font-size="${size}" fill="${color}">${escapeXml(value)}</text>`;
|
|
73
|
+
const body = [];
|
|
74
|
+
let y = 48;
|
|
75
|
+
for (const line of wrap(data.repo, 42)) { body.push(text(line, 32, y, 28, '#f4f7fd')); y += 36; }
|
|
76
|
+
for (const line of wrap(`Public aggregate analytics • Updated ${data.updatedAt || 'Unavailable'} (UTC)`, 65)) { body.push(text(line, 32, y)); y += 28; }
|
|
77
|
+
const cardTop = y + 12;
|
|
78
|
+
cards.forEach(([label, value, detail], index) => {
|
|
79
|
+
const x = 32 + index % 2 * 390;
|
|
80
|
+
const top = cardTop + Math.floor(index / 2) * 176;
|
|
81
|
+
body.push(`<rect x="${x}" y="${top}" width="366" height="160" rx="12" fill="#18253b"/>`);
|
|
82
|
+
wrap(label, 28).forEach((line, row) => body.push(text(line, x + 16, top + 30 + row * 25, 22)));
|
|
83
|
+
body.push(text(format(value), x + 16, top + 103, 40, '#7ee5c0'));
|
|
84
|
+
wrap(detail, 43).forEach((line, row) => body.push(text(line, x + 16, top + 132 + row * 18, 15)));
|
|
85
|
+
});
|
|
86
|
+
y = cardTop + cardRows * 176 + 16;
|
|
87
|
+
for (const caveat of ['Downloads and clones are not people or active users. CI, updates and reinstalls count.', 'Daily unique counts must not be added across dates. Missing values are unavailable, not zero.', data.usage ? 'Usage covers opted-in installations only; one person may use multiple installations.' : 'Active installation metrics unavailable; no aggregate usage report supplied.']) {
|
|
88
|
+
for (const line of wrap(caveat, 70)) { body.push(text(line, 32, y)); y += 28; }
|
|
89
|
+
y += 8;
|
|
90
|
+
}
|
|
91
|
+
for (const [title, ...lines] of details) {
|
|
92
|
+
y += 45;
|
|
93
|
+
body.push(text(title, 32, y, 22, '#f4f7fd'));
|
|
94
|
+
for (const line of lines.length ? lines : ['No data available']) {
|
|
95
|
+
for (const piece of wrap(line, 70)) { y += 28; body.push(text(piece, 32, y, 20)); }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="820" height="${y + 35}" viewBox="0 0 820 ${y + 35}" role="img" aria-label="Repository aggregate analytics"><rect width="100%" height="100%" fill="#0d1626"/><g font-family="Arial, sans-serif">${body.join('')}</g></svg>\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderLiveDashboard(data) {
|
|
102
|
+
const latest = source => [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row[source]);
|
|
103
|
+
const npm = latest('npm');
|
|
104
|
+
const initial = { usage: data.usage, repository: latest('repository') ? { ...latest('repository').repository, collectedAt: latest('repository').sourceCollectedAt.repository } : undefined, npm: npm ? { start: npm.npm.start, end: npm.npm.end, downloads: npm.npm.downloads.every(row => row.downloads != null) ? npm.npm.downloads.reduce((sum, row) => sum + row.downloads, 0) : null, collectedAt: npm.sourceCollectedAt.npm } : undefined, traffic: { views: latest('views')?.views, clones: latest('clones')?.clones, collectedAt: latest('views')?.sourceCollectedAt.views, viewsCollectedAt: latest('views')?.sourceCollectedAt.views, clonesCollectedAt: latest('clones')?.sourceCollectedAt.clones } };
|
|
105
|
+
initial.releases = latest('releases')?.releases;
|
|
106
|
+
initial.releasesCollectedAt = latest('releases')?.sourceCollectedAt.releases;
|
|
107
|
+
const serialized = JSON.stringify(initial).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
108
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src https://vdbwkowdggtcihixowxi.supabase.co; base-uri 'none'"><title>Live repository analytics</title><style>
|
|
109
|
+
*{box-sizing:border-box}body{margin:0;background:#0d1626;color:#f4f7fd;font:17px/1.6 system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:32px 20px}h1{font-size:32px;line-height:1.2}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0}article,.panel{background:#18253b;border-radius:12px;padding:20px}h2{font-size:19px;margin:0 0 8px}strong{font-size:38px;color:#7ee5c0;display:block}small,.muted{color:#acbcd0;display:block;font-size:14px}a{color:#9bcaff}.stale{color:#ffcb80}.panels{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px}ul{padding-left:20px}#status{min-height:28px}footer{margin:24px 0}</style></head><body><main><h1>${escapeXml(data.repo)}</h1><p>Live aggregate dashboard</p><p id="status" role="status">Loading current statistics…</p><p class="muted">Refreshes every 30 seconds while visible. GitHub and npm source data can be delayed; source timestamps appear on every card. Active installations include consenting installations only.</p><div class="cards" id="cards"></div><div class="panels"><section class="panel"><h2>Operating systems / 30 days</h2><ul id="platforms"></ul></section><section class="panel"><h2>Versions / 30 days</h2><ul id="versions"></ul></section></div><footer>Downloads and clones are not people. Never add daily unique counts across dates. Missing metrics are unavailable, not zero.<p><a href="https://github.com/${data.repo}/tree/analytics">Full archived daily history and release assets</a> · <a href="data.json">Aggregate JSON</a></p></footer></main><script>
|
|
110
|
+
let state = ${serialized};
|
|
111
|
+
const releasesPanel=document.createElement('section');releasesPanel.className='panel';const releasesTitle=document.createElement('h2');releasesTitle.textContent='Release assets';const releasesList=document.createElement('ul');releasesList.id='release-assets';releasesPanel.append(releasesTitle,releasesList);document.querySelector('.panels').append(releasesPanel);
|
|
112
|
+
const format = value => Number.isSafeInteger(value) && value >= 0 ? value.toLocaleString('en-US') : 'Unavailable';
|
|
113
|
+
const timestamp = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : 'Unavailable';
|
|
114
|
+
function render() {
|
|
115
|
+
const u = state.usage || {}, r = state.repository || {}, n = state.npm || {}, t = state.traffic || {};
|
|
116
|
+
const assets=Array.isArray(state.releases)?state.releases.flatMap(release=>(Array.isArray(release.assets)?release.assets:[]).map(asset=>({...asset,tag:release.tag}))):null;
|
|
117
|
+
const releaseTotal=assets && assets.every(asset=>Number.isSafeInteger(asset.downloads)&&asset.downloads>=0)?assets.reduce((sum,asset)=>sum+asset.downloads,0):null;
|
|
118
|
+
const rows = [['Reporting installations / day',u.active?.day,u.collectedAt],['Reporting installations / 7 days',u.active?.week,u.collectedAt],['Reporting installations / 30 days',u.active?.month,u.collectedAt],['GitHub views',t.views?.count,t.viewsCollectedAt || t.collectedAt],['Unique visitors',t.views?.uniques,t.viewsCollectedAt || t.collectedAt],['GitHub clones',t.clones?.count,t.clonesCollectedAt || t.collectedAt],['Unique cloners',t.clones?.uniques,t.clonesCollectedAt || t.collectedAt],['npm downloads',n.downloads,n.collectedAt,n.start && n.end ? n.start+' to '+n.end+' UTC' : 'Source window unavailable'],['Stars',r.stars,r.collectedAt],['Forks',r.forks,r.collectedAt],['Subscribers',r.subscribers,r.collectedAt],['Release downloads',releaseTotal,state.releasesCollectedAt,'Cumulative asset downloads']];
|
|
119
|
+
document.getElementById('cards').replaceChildren(...rows.map(([label,value,time,detail]) => { const card=document.createElement('article'); const title=document.createElement('h2');title.textContent=label;const count=document.createElement('strong');count.textContent=format(value);const source=document.createElement('small');source.textContent='Source: '+timestamp(time);card.append(title,count,source);if(detail){const note=document.createElement('small');note.textContent=detail;card.append(note);}return card;}));
|
|
120
|
+
for(const [id,key] of [['platforms','platform'],['versions','version']]){const list=Array.isArray(u[id])?u[id]:[];document.getElementById(id).replaceChildren(...(list.length?list:[{}]).map(row=>{const item=document.createElement('li');item.textContent=row[key]?String(row[key])+': '+format(row.installations):'Unavailable';return item;}));}
|
|
121
|
+
releasesList.replaceChildren(...(assets?.length?assets:[null]).map(asset=>{const item=document.createElement('li');item.textContent=asset?String(asset.tag)+' / '+String(asset.name)+': '+format(asset.downloads):assets?'No release assets':'Unavailable';return item;}));
|
|
122
|
+
}
|
|
123
|
+
let pending = false;
|
|
124
|
+
async function refresh() {
|
|
125
|
+
if(pending || document.hidden) return;
|
|
126
|
+
pending=true;
|
|
127
|
+
const status=document.getElementById('status');
|
|
128
|
+
try {
|
|
129
|
+
const response=await fetch('https://vdbwkowdggtcihixowxi.supabase.co/functions/v1/bridge-stats',{headers:{apikey:'sb_publishable_2GIhGKL82wM8mN44L-Gzxw_Zgd-Vioh'},cache:'no-store',signal:AbortSignal.timeout(8000)});
|
|
130
|
+
if(!response.ok) throw new Error('request failed');
|
|
131
|
+
const next=await response.json();
|
|
132
|
+
if(!next || typeof next!=='object' || !next.collectedAt) throw new Error('invalid response');
|
|
133
|
+
for(const source of ['usage','repository','npm','traffic']){if(next[source] && typeof next[source]==='object') state[source]=next[source];}
|
|
134
|
+
if(Array.isArray(next.releases)){state.releases=next.releases;state.releasesCollectedAt=next.releasesCollectedAt || next.sourceCollectedAt?.releases || null;}
|
|
135
|
+
render();const partial=Array.isArray(next.errors)&&next.errors.length>0;status.className=partial?'stale':'';status.textContent=(partial?'Some sources stale · Last refresh: ':'Last refresh: ')+new Date().toISOString()+' · Source delays still apply';
|
|
136
|
+
}catch{status.className='stale';status.textContent='Refresh failed — showing previously available values. Retrying in 30 seconds.';}
|
|
137
|
+
finally{pending=false;}
|
|
138
|
+
}
|
|
139
|
+
render();void refresh();setInterval(()=>{void refresh();},30000);document.addEventListener('visibilitychange',()=>{if(!document.hidden) void refresh();});
|
|
140
|
+
</script></body></html>\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function publicFiles(history, usage) {
|
|
144
|
+
const data = sanitizeAnalytics(history, usage);
|
|
145
|
+
const usageDays = Object.fromEntries((data.usage?.daily || []).map(row => [row.day, row.installations]));
|
|
146
|
+
const days = [...new Set([...Object.keys(data.daily.views), ...Object.keys(data.daily.clones), ...Object.keys(data.daily.npm), ...Object.keys(usageDays)])].sort().reverse();
|
|
147
|
+
const table = ['| Date (UTC) | Views | Unique visitors | Clones | Unique cloners | npm downloads | Active installations |', '|---|---:|---:|---:|---:|---:|---:|', ...days.map(day => `| ${day} | ${[data.daily.views[day]?.count, data.daily.views[day]?.uniques, data.daily.clones[day]?.count, data.daily.clones[day]?.uniques, data.daily.npm[day]?.downloads, usageDays[day]].map(format).join(' | ')} |`)].join('\n');
|
|
148
|
+
const readme = `# Public repository analytics\n\n\n\n[Aggregate JSON history](data.json) · [Repository](https://github.com/${data.repo})\n\nAll dates and source windows use UTC. Source timestamps on the dashboard show when each metric was last available. Downloads and clones include automation and do not measure people. Unique visitors/cloners apply only to the source window; never sum daily unique counts across dates. Active installations cover consenting installations only, with distinct counts for each whole period. Operating system and version breakdowns cover 30 days; an installation may appear in multiple version groups after upgrading.\n\nThis branch contains only aggregate statistics. No installation identifiers, prompts, account details, credentials, private error messages, or local paths are published. Missing metrics mean unavailable, not zero.\n\n## Daily history\n\nEvery archived source day is listed below, newest first. Active installations count consenting installations that reported that UTC day.\n\n${table}\n`;
|
|
149
|
+
const pagesUrl = `https://${data.repo.split('/')[0]}.github.io/${data.repo.split('/')[1]}/`;
|
|
150
|
+
return { 'dashboard.svg': renderPublicDashboard(data), 'README.md': readme.replace('![Repository dashboard]', `[Live dashboard · refreshes every 30 seconds](${pagesUrl})\n\n![Repository dashboard]`), 'data.json': `${JSON.stringify(data, null, 2)}\n`, 'index.html': renderLiveDashboard(data) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function githubApi(endpoint, { method = 'GET', body } = {}) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const args = ['api', '--hostname', 'github.com', '--method', method, endpoint];
|
|
156
|
+
if (body) args.push('--input', '-');
|
|
157
|
+
const child = spawn('gh', args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
158
|
+
let stdout = '';
|
|
159
|
+
let stderr = '';
|
|
160
|
+
const timer = setTimeout(() => child.kill(), 60000);
|
|
161
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
162
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
163
|
+
child.on('error', () => { clearTimeout(timer); reject(new Error('GitHub CLI could not start.')); });
|
|
164
|
+
child.on('close', code => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
if (code !== 0) { const error = new Error('GitHub API request failed. Check authentication, write permissions, and concurrent updates.'); error.status = /HTTP 404/.test(stderr) ? 404 : null; reject(error); return; }
|
|
167
|
+
try { resolve(JSON.parse(stdout)); } catch { reject(new Error('Invalid GitHub API response.')); }
|
|
168
|
+
});
|
|
169
|
+
child.stdin.on('error', () => {});
|
|
170
|
+
child.stdin.end(body ? JSON.stringify(body) : undefined);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function publishAnalytics(repo, files, gh = githubApi) {
|
|
175
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error('Invalid repository.');
|
|
176
|
+
let previous;
|
|
177
|
+
try { previous = (await gh(`repos/${repo}/git/ref/heads/analytics`)).object.sha; }
|
|
178
|
+
catch (error) { if (error.status !== 404) throw error; }
|
|
179
|
+
const tree = await gh(`repos/${repo}/git/trees`, { method: 'POST', body: { tree: Object.entries(files).map(([filename, content]) => ({ path: filename, mode: '100644', type: 'blob', content })) } });
|
|
180
|
+
const commit = await gh(`repos/${repo}/git/commits`, { method: 'POST', body: { message: 'docs: refresh public aggregate analytics', tree: tree.sha, parents: previous ? [previous] : [] } });
|
|
181
|
+
if (previous) await gh(`repos/${repo}/git/refs/heads/analytics`, { method: 'PATCH', body: { sha: commit.sha, force: false } });
|
|
182
|
+
else await gh(`repos/${repo}/git/refs`, { method: 'POST', body: { ref: 'refs/heads/analytics', sha: commit.sha } });
|
|
183
|
+
return commit.sha;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main(args) {
|
|
187
|
+
const options = { history: path.join(defaultOutput(), 'history.json') };
|
|
188
|
+
for (let i = 0; i < args.length; i++) {
|
|
189
|
+
if (!['--history', '--usage', '--dry-run'].includes(args[i]) || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error('Usage: node scripts/publish-repo-analytics.mjs [--history file] [--usage file] [--dry-run directory]');
|
|
190
|
+
options[args[i].slice(2)] = args[++i];
|
|
191
|
+
}
|
|
192
|
+
const history = JSON.parse(await readFile(options.history, 'utf8'));
|
|
193
|
+
const usage = options.usage ? JSON.parse(await readFile(options.usage, 'utf8')) : undefined;
|
|
194
|
+
const files = publicFiles(history, usage);
|
|
195
|
+
if (options['dry-run']) {
|
|
196
|
+
const directory = path.resolve(options['dry-run']);
|
|
197
|
+
const workspace = path.resolve(process.cwd());
|
|
198
|
+
if (directory === workspace || directory.startsWith(`${workspace}${path.sep}`)) throw new Error('Dry-run directory must be outside the current workspace.');
|
|
199
|
+
await mkdir(directory, { recursive: true });
|
|
200
|
+
for (const [filename, content] of Object.entries(files)) await writeFile(path.join(directory, filename), content);
|
|
201
|
+
console.log(`Public analytics preview: ${directory}`);
|
|
202
|
+
} else {
|
|
203
|
+
const sha = await publishAnalytics(history.repo, files);
|
|
204
|
+
console.log(`Published analytics: https://github.com/${history.repo}/tree/analytics (${sha})`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main(process.argv.slice(2)).catch(() => { console.error('Public analytics publish failed. Check input files, GitHub permissions, and concurrent updates.'); process.exitCode = 1; });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectSources, mergeHistory } from './collect-repo-analytics.mjs';
|
|
4
|
+
import { githubApi, publicFiles, publishAnalytics, sanitizeAnalytics } from './publish-repo-analytics.mjs';
|
|
5
|
+
import { TELEMETRY_PUBLIC_KEY } from '../src/telemetry-config.mjs';
|
|
6
|
+
|
|
7
|
+
const repo = 'buidangminh23/codex-mcp-bridge';
|
|
8
|
+
const file = await githubApi(`repos/${repo}/contents/data.json?ref=analytics`);
|
|
9
|
+
const previous = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
|
|
10
|
+
const options = { repo, package: '@minhspark/codex-mcp-bridge' };
|
|
11
|
+
const snapshot = await collectSources(options);
|
|
12
|
+
const history = mergeHistory(previous, options, snapshot);
|
|
13
|
+
let usage = previous.usage;
|
|
14
|
+
let usageFailed = false;
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch('https://vdbwkowdggtcihixowxi.supabase.co/functions/v1/bridge-stats', {
|
|
17
|
+
headers: { apikey: TELEMETRY_PUBLIC_KEY }, signal: AbortSignal.timeout(15000),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) throw new Error('Stats unavailable');
|
|
20
|
+
const data = await response.json();
|
|
21
|
+
if (!data.usage || data.errors?.some(row => row.source === 'usage')) throw new Error('Usage unavailable');
|
|
22
|
+
usage = sanitizeAnalytics(history, data.usage).usage;
|
|
23
|
+
} catch { usageFailed = true; }
|
|
24
|
+
const sha = await publishAnalytics(repo, publicFiles(history, usage));
|
|
25
|
+
const ref = await githubApi(`repos/${repo}/git/ref/heads/analytics`);
|
|
26
|
+
if (ref.object.sha !== sha) throw new Error('Analytics publication changed during verification');
|
|
27
|
+
const directory = path.resolve('public-analytics');
|
|
28
|
+
await mkdir(directory, { recursive: true });
|
|
29
|
+
for (const [name, content] of Object.entries(publicFiles(history, usage))) await writeFile(path.join(directory, name), content);
|
|
30
|
+
console.log(`Published aggregate analytics: ${sha}`);
|
|
31
|
+
for (const error of snapshot.errors) {
|
|
32
|
+
const status = error.status ? ` (HTTP ${error.status})` : '';
|
|
33
|
+
const hint = error.status === 403 && ['views', 'clones'].includes(error.source) ? ' GitHub traffic requires the ANALYTICS_TOKEN secret to be a token with push access (fine-grained: Administration read); the default workflow token cannot read traffic.' : '';
|
|
34
|
+
console.error(`::warning::${error.source} was not refreshed${status}; previous data retained.${hint}`);
|
|
35
|
+
}
|
|
36
|
+
if (usageFailed) console.error('::warning::Usage was not refreshed; previous data retained.');
|
|
37
|
+
if (snapshot.errors.length || usageFailed) process.exitCode = 1;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
|
2
|
+
const number = (value) => Number.isFinite(value) ? value.toLocaleString("en-US") : "Unavailable";
|
|
3
|
+
|
|
4
|
+
function seriesChart(entries, label, color) {
|
|
5
|
+
const width = 760;
|
|
6
|
+
const height = 160;
|
|
7
|
+
const values = entries.map(([, row]) => row.downloads ?? row.count ?? 0);
|
|
8
|
+
const maximum = Math.max(1, ...values);
|
|
9
|
+
const bars = entries.map(([day], index) => {
|
|
10
|
+
const slot = width / Math.max(1, entries.length);
|
|
11
|
+
const barHeight = values[index] / maximum * (height - 20);
|
|
12
|
+
return `<rect x="${index * slot + 1}" y="${height - barHeight}" width="${Math.max(1, slot - 2)}" height="${barHeight}" rx="2" fill="${color}"><title>${escapeHtml(day)}: ${number(values[index])}</title></rect>`;
|
|
13
|
+
}).join("");
|
|
14
|
+
return `<section class="chart"><h2>${escapeHtml(label)}</h2><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(label)} by source day">${bars}</svg><div class="axis"><span>${escapeHtml(entries[0]?.[0] ?? "No data")}</span><span>${escapeHtml(entries.at(-1)?.[0] ?? "")}</span></div></section>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function lastAvailable(snapshots, field) {
|
|
18
|
+
return [...snapshots].reverse().find((snapshot) => snapshot[field] != null)?.[field];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderAnalytics(history) {
|
|
22
|
+
const snapshots = [...(history.snapshots ?? [])].sort((left, right) => left.collectedAt.localeCompare(right.collectedAt));
|
|
23
|
+
const latest = snapshots.at(-1);
|
|
24
|
+
const views = lastAvailable(snapshots, "views");
|
|
25
|
+
const clones = lastAvailable(snapshots, "clones");
|
|
26
|
+
const repository = lastAvailable(snapshots, "repository");
|
|
27
|
+
const npm = lastAvailable(snapshots, "npm");
|
|
28
|
+
const daily = history.daily ?? {};
|
|
29
|
+
const entries = (source) => Object.entries(daily[source] ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
30
|
+
const days = [...new Set([...Object.keys(daily.views ?? {}), ...Object.keys(daily.clones ?? {}), ...Object.keys(daily.npm ?? {})])].sort().reverse();
|
|
31
|
+
const npmTotal = npm?.downloads?.reduce((sum, row) => sum + row.downloads, 0);
|
|
32
|
+
const range = (rows) => rows?.length ? `${rows[0].timestamp.slice(0, 10)} — ${rows.at(-1).timestamp.slice(0, 10)} UTC` : "No source data";
|
|
33
|
+
const card = (label, value, detail) => `<article class="card"><p>${label}</p><strong>${number(value)}</strong><small>${escapeHtml(detail)}</small></article>`;
|
|
34
|
+
const errors = latest?.errors ?? [];
|
|
35
|
+
const freshness = ["views", "clones", "npm", "repository", "releases"].map((source) => {
|
|
36
|
+
const snapshot = [...snapshots].reverse().find((row) => row[source] != null);
|
|
37
|
+
return `${source}: ${snapshot?.sourceCollectedAt?.[source] ?? snapshot?.collectedAt ?? "Unavailable"}`;
|
|
38
|
+
}).join(" · ");
|
|
39
|
+
const rows = days.map((day) => `<tr><td>${escapeHtml(day)}</td><td>${number(daily.views?.[day]?.count)}</td><td>${number(daily.views?.[day]?.uniques)}</td><td>${number(daily.clones?.[day]?.count)}</td><td>${number(daily.clones?.[day]?.uniques)}</td><td>${number(daily.npm?.[day]?.downloads)}</td></tr>`).join("");
|
|
40
|
+
return `<!doctype html>
|
|
41
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'"><title>Repository analytics · ${escapeHtml(history.repo)}</title>
|
|
42
|
+
<style>*{box-sizing:border-box}body{margin:0;background:#0d1320;color:#edf2fa;font:15px/1.6 system-ui,sans-serif}main{max-width:1180px;margin:auto;padding:40px 24px}h1{font-size:32px;line-height:1.2;margin:12px 0}h2{font-size:18px}p{margin:8px 0}.muted,small,.axis{color:#a8b5c8}a{color:#94cfff}.badge{font-size:12px;letter-spacing:.12em;color:#6ee7be;text-transform:uppercase}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:28px 0}.card,.chart,.notice{background:#172133;border:1px solid #2b3b54;border-radius:14px;padding:20px}.card p{color:#a8b5c8}.card strong{display:block;font-size:36px;line-height:1.5}.card small{display:block}.charts{display:grid;gap:20px}.chart svg{width:100%;height:auto;max-height:180px;display:block}.axis{display:flex;justify-content:space-between;font-size:12px}.notice{margin:20px 0}.warning{border-color:#b98938}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums;white-space:nowrap}th,td{padding:10px 14px;border-bottom:1px solid #2b3b54;text-align:right}th:first-child,td:first-child{text-align:left}th{color:#a8b5c8;font-size:12px}details{margin-top:28px}summary{cursor:pointer;font-weight:600}footer{margin-top:32px;color:#a8b5c8;font-size:13px}@media(max-width:600px){main{padding:24px 16px}h1{font-size:25px}}</style></head>
|
|
43
|
+
<body><main><div class="badge">Private local report · UTC dates</div><h1>${escapeHtml(history.repo)}</h1><p class="muted">GitHub traffic and npm downloads, archived over time.</p><p class="muted">Last collection: ${escapeHtml(latest?.collectedAt ?? "Not collected")}</p>
|
|
44
|
+
${errors.length ? `<div class="notice warning"><strong>Partial collection</strong><p>Some sources could not be refreshed. Available values may come from an earlier successful collection.</p><ul>${errors.map((error) => `<li>${escapeHtml(typeof error === "string" ? error : `${error.source ?? "Source"}: ${error.message ?? error.error ?? "Unavailable"}`)}</li>`).join("")}</ul></div>` : ""}
|
|
45
|
+
<div class="cards">${card("GitHub views", views?.count, range(views?.views))}${card("Unique visitors", views?.uniques, "Distinct visitors in the reported GitHub window")}${card("GitHub clones", clones?.count, range(clones?.clones))}${card("Unique cloners", clones?.uniques, "Distinct cloners in the reported GitHub window")}${card("npm downloads", npmTotal, npm ? `${npm.start} — ${npm.end} UTC` : "Unavailable")}${card("Stars", repository?.stars, `${number(repository?.forks)} forks · ${number(repository?.subscribers)} subscribers`)}</div>
|
|
46
|
+
<div class="notice"><strong>Downloads are not active users.</strong><p>Reinstalls, updates, automation, and CI can contribute to downloads or clones. Daily unique counts must not be added to estimate unique people across multiple days. App usage measurement is not enabled by this report.</p></div>
|
|
47
|
+
<div class="charts">${seriesChart(entries("npm").slice(-90), "npm downloads · last 90 archived days", "#68d7b0")}${seriesChart(entries("views").slice(-90), "GitHub views · last 90 archived days", "#73b7f3")}${seriesChart(entries("clones").slice(-90), "GitHub clones · last 90 archived days", "#b3a0fb")}</div>
|
|
48
|
+
<details open><summary>Daily history (${days.length} source days)</summary><div class="table-wrap"><table><thead><tr><th>Date (UTC)</th><th>Views</th><th>Unique visitors</th><th>Clones</th><th>Unique cloners</th><th>npm downloads</th></tr></thead><tbody>${rows}</tbody></table></div></details>
|
|
49
|
+
<footer><p>Source collection times: ${escapeHtml(freshness)}</p>Missing values mean unavailable, not zero. Repeated collection updates overlapping dates rather than adding them twice. Keep history.json to preserve the archive. This report has no tracking scripts or external resources.</footer></main></body></html>`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
SELECT json_build_object(
|
|
2
|
+
'collectedAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
|
3
|
+
'active', (SELECT json_build_object(
|
|
4
|
+
'day', count(DISTINCT install_id) FILTER (WHERE day = (now() AT TIME ZONE 'UTC')::date),
|
|
5
|
+
'week', count(DISTINCT install_id) FILTER (WHERE day >= (now() AT TIME ZONE 'UTC')::date - 6),
|
|
6
|
+
'month', count(DISTINCT install_id) FILTER (WHERE day >= (now() AT TIME ZONE 'UTC')::date - 29)
|
|
7
|
+
) FROM public.bridge_usage_daily),
|
|
8
|
+
'daily', (SELECT coalesce(json_agg(rows ORDER BY day), '[]'::json) FROM (
|
|
9
|
+
SELECT day, count(*) AS installations FROM public.bridge_usage_daily GROUP BY day
|
|
10
|
+
) rows),
|
|
11
|
+
'platforms', (SELECT coalesce(json_agg(rows ORDER BY platform), '[]'::json) FROM (
|
|
12
|
+
SELECT platform, count(DISTINCT install_id) AS installations FROM public.bridge_usage_daily
|
|
13
|
+
WHERE day >= (now() AT TIME ZONE 'UTC')::date - 29 GROUP BY platform
|
|
14
|
+
) rows),
|
|
15
|
+
'versions', (SELECT coalesce(json_agg(rows ORDER BY version), '[]'::json) FROM (
|
|
16
|
+
SELECT version, count(DISTINCT install_id) AS installations FROM public.bridge_usage_daily
|
|
17
|
+
WHERE day >= (now() AT TIME ZONE 'UTC')::date - 29 GROUP BY version
|
|
18
|
+
) rows)
|
|
19
|
+
) AS summary;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runSupervisor } from "./mcp-supervisor.mjs";
|
|
3
|
-
|
|
3
|
+
import { handleTelemetryCommand } from "./telemetry.mjs";
|
|
4
|
+
if (!await handleTelemetryCommand(process.argv.slice(2))) await runSupervisor("claude-bridge.mjs");
|