@mintlify/cli 4.0.1084 → 4.0.1085
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/__test__/analytics/client.test.ts +158 -0
- package/__test__/analytics/format.test.ts +139 -0
- package/bin/analytics/client.js +73 -0
- package/bin/analytics/format.js +13 -0
- package/bin/analytics/index.js +524 -0
- package/bin/analytics/output.js +74 -0
- package/bin/analytics/types.js +1 -0
- package/bin/cli.js +56 -0
- package/bin/config.js +14 -0
- package/bin/keyring.js +5 -2
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/analytics/client.ts +164 -0
- package/src/analytics/format.ts +13 -0
- package/src/analytics/index.tsx +620 -0
- package/src/analytics/output.ts +97 -0
- package/src/analytics/types.ts +132 -0
- package/src/cli.tsx +88 -0
- package/src/config.ts +16 -0
- package/src/keyring.ts +3 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getFeedback,
|
|
3
|
+
getFeedbackByPage,
|
|
4
|
+
getBuckets,
|
|
5
|
+
getBucketThreads,
|
|
6
|
+
getConversations,
|
|
7
|
+
getKpi,
|
|
8
|
+
getSearches,
|
|
9
|
+
getViews,
|
|
10
|
+
getVisitors,
|
|
11
|
+
} from '../../src/analytics/client.js';
|
|
12
|
+
|
|
13
|
+
vi.mock('../../src/keyring.js', () => ({
|
|
14
|
+
getAccessToken: vi.fn().mockResolvedValue(null),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockFetch = vi.fn();
|
|
18
|
+
global.fetch = mockFetch;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.stubEnv('MINTLIFY_SESSION_TOKEN', 'test-token');
|
|
22
|
+
vi.stubEnv('MINTLIFY_API_URL', 'http://test-server:5000');
|
|
23
|
+
mockFetch.mockReset();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllEnvs();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function mockOk(data: unknown) {
|
|
31
|
+
mockFetch.mockResolvedValueOnce({
|
|
32
|
+
ok: true,
|
|
33
|
+
json: () => Promise.resolve(data),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mockError(status: number, body: string) {
|
|
38
|
+
mockFetch.mockResolvedValueOnce({
|
|
39
|
+
ok: false,
|
|
40
|
+
status,
|
|
41
|
+
statusText: 'Bad Request',
|
|
42
|
+
text: () => Promise.resolve(body),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function calledUrl(): URL {
|
|
47
|
+
return mockFetch.mock.calls[0]![0] as URL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('client auth', () => {
|
|
51
|
+
it('throws when no session token is set', async () => {
|
|
52
|
+
vi.stubEnv('MINTLIFY_SESSION_TOKEN', '');
|
|
53
|
+
await expect(getFeedback({ dateFrom: '2024-01-01', dateTo: '2024-01-31' })).rejects.toThrow(
|
|
54
|
+
'Not authenticated'
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('sends session cookie header', async () => {
|
|
59
|
+
mockOk({ feedback: [], nextCursor: null, hasMore: false });
|
|
60
|
+
await getFeedback({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'test');
|
|
61
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
62
|
+
expect.any(URL),
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
headers: expect.objectContaining({
|
|
65
|
+
Authorization: 'Bearer test-token',
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('client request handling', () => {
|
|
73
|
+
it('throws on API error', async () => {
|
|
74
|
+
mockError(400, 'Invalid params');
|
|
75
|
+
await expect(getFeedback({ dateFrom: '2024-01-01', dateTo: '2024-01-31' })).rejects.toThrow(
|
|
76
|
+
'API error (400): Invalid params'
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('passes subdomain as query param when provided', async () => {
|
|
81
|
+
mockOk({ feedback: [], nextCursor: null, hasMore: false });
|
|
82
|
+
await getFeedback({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'my-docs');
|
|
83
|
+
expect(calledUrl().searchParams.get('subdomain')).toBe('my-docs');
|
|
84
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/feedback');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('omits subdomain param when not provided', async () => {
|
|
88
|
+
mockOk({ feedback: [], nextCursor: null, hasMore: false });
|
|
89
|
+
await getFeedback({ dateFrom: '2024-01-01', dateTo: '2024-01-31' });
|
|
90
|
+
expect(calledUrl().searchParams.has('subdomain')).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sets query params and omits undefined values', async () => {
|
|
94
|
+
mockOk({ feedback: [], nextCursor: null, hasMore: false });
|
|
95
|
+
await getFeedback({
|
|
96
|
+
dateFrom: '2024-01-01',
|
|
97
|
+
dateTo: '2024-01-31',
|
|
98
|
+
limit: 10,
|
|
99
|
+
cursor: undefined,
|
|
100
|
+
});
|
|
101
|
+
expect(calledUrl().searchParams.get('dateFrom')).toBe('2024-01-01');
|
|
102
|
+
expect(calledUrl().searchParams.get('limit')).toBe('10');
|
|
103
|
+
expect(calledUrl().searchParams.has('cursor')).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('endpoint paths', () => {
|
|
108
|
+
it('getKpi', async () => {
|
|
109
|
+
mockOk({ humanVisitors: 0 });
|
|
110
|
+
await getKpi({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
111
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/kpi');
|
|
112
|
+
expect(calledUrl().searchParams.get('subdomain')).toBe('docs');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('getFeedbackByPage', async () => {
|
|
116
|
+
mockOk({ feedback: [], hasMore: false });
|
|
117
|
+
await getFeedbackByPage({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
118
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/feedback/by-page');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('getConversations', async () => {
|
|
122
|
+
mockOk({ conversations: [], nextCursor: null, hasMore: false });
|
|
123
|
+
await getConversations({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
124
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/assistant');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('getSearches', async () => {
|
|
128
|
+
mockOk({ searches: [], totalSearches: 0, nextCursor: null });
|
|
129
|
+
await getSearches({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
130
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/searches');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('getViews', async () => {
|
|
134
|
+
mockOk({ totals: {}, views: [], hasMore: false });
|
|
135
|
+
await getViews({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
136
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/views');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('getVisitors', async () => {
|
|
140
|
+
mockOk({ totals: {}, visitors: [], hasMore: false });
|
|
141
|
+
await getVisitors({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
142
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/visitors');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('getBuckets', async () => {
|
|
146
|
+
mockOk({ data: [], pagination: { total: 0 } });
|
|
147
|
+
await getBuckets({ dateFrom: '2024-01-01', dateTo: '2024-01-31' }, 'docs');
|
|
148
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/conversations/buckets');
|
|
149
|
+
expect(calledUrl().searchParams.get('subdomain')).toBe('docs');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('getBucketThreads', async () => {
|
|
153
|
+
mockOk({ data: [], pagination: { total: 0, hasMore: false, nextCursor: null } });
|
|
154
|
+
await getBucketThreads('bucket-123', { dateFrom: '2024-01-01' }, 'docs');
|
|
155
|
+
expect(calledUrl().pathname).toBe('/api/cli/analytics/conversations/buckets/bucket-123');
|
|
156
|
+
expect(calledUrl().searchParams.get('subdomain')).toBe('docs');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { num, pct, truncate } from '../../src/analytics/format.js';
|
|
2
|
+
import {
|
|
3
|
+
formatBarChart,
|
|
4
|
+
formatPlainTable,
|
|
5
|
+
formatPrettyTable,
|
|
6
|
+
resolveFormat,
|
|
7
|
+
} from '../../src/analytics/output.js';
|
|
8
|
+
|
|
9
|
+
describe('num', () => {
|
|
10
|
+
it('formats numbers with locale separators', () => {
|
|
11
|
+
expect(num(0)).toBe('0');
|
|
12
|
+
expect(num(1234)).toBe('1,234');
|
|
13
|
+
expect(num(1000000)).toBe('1,000,000');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('pct', () => {
|
|
18
|
+
it('returns percentage string', () => {
|
|
19
|
+
expect(pct(1, 4)).toBe('25.0%');
|
|
20
|
+
expect(pct(1, 3)).toBe('33.3%');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns dash when total is zero', () => {
|
|
24
|
+
expect(pct(0, 0)).toBe('\u2014');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('truncate', () => {
|
|
29
|
+
it('returns short strings unchanged', () => {
|
|
30
|
+
expect(truncate('hello', 10)).toBe('hello');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('truncates long strings with ellipsis', () => {
|
|
34
|
+
expect(truncate('hello world', 6)).toBe('hello\u2026');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns string unchanged when exactly at max', () => {
|
|
38
|
+
expect(truncate('hello', 5)).toBe('hello');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('formatPrettyTable', () => {
|
|
43
|
+
it('returns dim message for empty rows', () => {
|
|
44
|
+
const result = formatPrettyTable(['A', 'B'], []);
|
|
45
|
+
expect(result).toContain('No data found');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('formats headers and rows with aligned columns', () => {
|
|
49
|
+
const result = formatPrettyTable(
|
|
50
|
+
['Name', 'Count'],
|
|
51
|
+
[
|
|
52
|
+
['foo', '10'],
|
|
53
|
+
['barbaz', '5'],
|
|
54
|
+
]
|
|
55
|
+
);
|
|
56
|
+
const lines = result.split('\n');
|
|
57
|
+
expect(lines).toHaveLength(4);
|
|
58
|
+
expect(lines[2]).toContain('foo');
|
|
59
|
+
expect(lines[3]).toContain('barbaz');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('formatPlainTable', () => {
|
|
64
|
+
it('returns empty string for empty rows', () => {
|
|
65
|
+
expect(formatPlainTable(['A', 'B'], [])).toBe('');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('formats with tab separation and uppercase headers', () => {
|
|
69
|
+
const result = formatPlainTable(
|
|
70
|
+
['Name', 'Count'],
|
|
71
|
+
[
|
|
72
|
+
['foo', '10'],
|
|
73
|
+
['bar', '5'],
|
|
74
|
+
]
|
|
75
|
+
);
|
|
76
|
+
const lines = result.split('\n');
|
|
77
|
+
expect(lines[0]).toContain('NAME');
|
|
78
|
+
expect(lines[0]).toContain('\t');
|
|
79
|
+
expect(lines[1]).toContain('foo');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('resolveFormat', () => {
|
|
84
|
+
it('returns json when agent flag is set', () => {
|
|
85
|
+
expect(resolveFormat({ agent: true })).toBe('json');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns json when CLAUDECODE env is set', () => {
|
|
89
|
+
const prev = process.env.CLAUDECODE;
|
|
90
|
+
process.env.CLAUDECODE = '1';
|
|
91
|
+
expect(resolveFormat({})).toBe('json');
|
|
92
|
+
process.env.CLAUDECODE = prev;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns specified format', () => {
|
|
96
|
+
expect(resolveFormat({ format: 'plain' })).toBe('plain');
|
|
97
|
+
expect(resolveFormat({ format: 'json' })).toBe('json');
|
|
98
|
+
expect(resolveFormat({ format: 'graph' })).toBe('graph');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('defaults to plain', () => {
|
|
102
|
+
const prev = process.env.CLAUDECODE;
|
|
103
|
+
delete process.env.CLAUDECODE;
|
|
104
|
+
expect(resolveFormat({})).toBe('plain');
|
|
105
|
+
process.env.CLAUDECODE = prev;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('formatBarChart', () => {
|
|
110
|
+
it('returns dim message for empty items', () => {
|
|
111
|
+
expect(formatBarChart([])).toContain('No data found');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('renders bars with labels and values', () => {
|
|
115
|
+
const result = formatBarChart([
|
|
116
|
+
{ label: 'Foo', value: 10 },
|
|
117
|
+
{ label: 'Bar', value: 5 },
|
|
118
|
+
]);
|
|
119
|
+
const lines = result.split('\n');
|
|
120
|
+
expect(lines).toHaveLength(2);
|
|
121
|
+
expect(lines[0]).toContain('Foo');
|
|
122
|
+
expect(lines[0]).toContain('10');
|
|
123
|
+
expect(lines[0]).toContain('\u2588');
|
|
124
|
+
expect(lines[1]).toContain('Bar');
|
|
125
|
+
expect(lines[1]).toContain('5');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('scales bars relative to max value', () => {
|
|
129
|
+
const result = formatBarChart([
|
|
130
|
+
{ label: 'Big', value: 100 },
|
|
131
|
+
{ label: 'Small', value: 10 },
|
|
132
|
+
]);
|
|
133
|
+
const bigBar = result.split('\n')[0]!;
|
|
134
|
+
const smallBar = result.split('\n')[1]!;
|
|
135
|
+
const bigBlocks = (bigBar.match(/\u2588/g) || []).length;
|
|
136
|
+
const smallBlocks = (smallBar.match(/\u2588/g) || []).length;
|
|
137
|
+
expect(bigBlocks).toBeGreaterThan(smallBlocks);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
var _a;
|
|
11
|
+
const API_BASE = (_a = process.env.MINTLIFY_API_URL) !== null && _a !== void 0 ? _a : 'http://localhost:5000';
|
|
12
|
+
function getAuthHeaders() {
|
|
13
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
14
|
+
try {
|
|
15
|
+
const { getAccessToken } = yield import('../keyring.js');
|
|
16
|
+
const token = yield getAccessToken();
|
|
17
|
+
if (token) {
|
|
18
|
+
return { Authorization: `Bearer ${token}` };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (_a) { }
|
|
22
|
+
const envToken = process.env.MINTLIFY_SESSION_TOKEN;
|
|
23
|
+
if (envToken) {
|
|
24
|
+
return { Authorization: `Bearer ${envToken}` };
|
|
25
|
+
}
|
|
26
|
+
throw new Error('Not authenticated. Run `mint login` to authenticate.');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function request(path_1) {
|
|
30
|
+
return __awaiter(this, arguments, void 0, function* (path, params = {}) {
|
|
31
|
+
const url = new URL(`${API_BASE}/api/cli/analytics${path}`);
|
|
32
|
+
for (const [key, value] of Object.entries(params)) {
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
url.searchParams.set(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
const authHeaders = yield getAuthHeaders();
|
|
37
|
+
const res = yield fetch(url, {
|
|
38
|
+
headers: Object.assign(Object.assign({}, authHeaders), { Accept: 'application/json' }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const body = yield res.text().catch(() => '');
|
|
42
|
+
throw new Error(`API error (${res.status}): ${body || res.statusText}`);
|
|
43
|
+
}
|
|
44
|
+
return res.json();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function getKpi(opts, subdomain) {
|
|
48
|
+
return request('/kpi', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
49
|
+
}
|
|
50
|
+
export function getFeedback(opts, subdomain) {
|
|
51
|
+
return request('/feedback', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
52
|
+
}
|
|
53
|
+
export function getFeedbackByPage(opts, subdomain) {
|
|
54
|
+
return request('/feedback/by-page', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
55
|
+
}
|
|
56
|
+
export function getConversations(opts, subdomain) {
|
|
57
|
+
return request('/assistant', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
58
|
+
}
|
|
59
|
+
export function getSearches(opts, subdomain) {
|
|
60
|
+
return request('/searches', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
61
|
+
}
|
|
62
|
+
export function getViews(opts, subdomain) {
|
|
63
|
+
return request('/views', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
64
|
+
}
|
|
65
|
+
export function getVisitors(opts, subdomain) {
|
|
66
|
+
return request('/visitors', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
67
|
+
}
|
|
68
|
+
export function getBuckets(opts, subdomain) {
|
|
69
|
+
return request('/conversations/buckets', Object.assign(Object.assign({}, opts), { subdomain }));
|
|
70
|
+
}
|
|
71
|
+
export function getBucketThreads(bucketId, opts, subdomain) {
|
|
72
|
+
return request(`/conversations/buckets/${encodeURIComponent(bucketId)}`, Object.assign(Object.assign({}, opts), { subdomain }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function num(n) {
|
|
2
|
+
return n.toLocaleString('en-US');
|
|
3
|
+
}
|
|
4
|
+
export function pct(n, total) {
|
|
5
|
+
if (total === 0)
|
|
6
|
+
return '\u2014';
|
|
7
|
+
return ((n / total) * 100).toFixed(1) + '%';
|
|
8
|
+
}
|
|
9
|
+
export function truncate(s, max) {
|
|
10
|
+
if (s.length <= max)
|
|
11
|
+
return s;
|
|
12
|
+
return s.slice(0, max - 1) + '\u2026';
|
|
13
|
+
}
|