@meltstudio/meltctl 4.32.0 → 4.33.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/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { feedbackCommand } from './commands/feedback.js';
|
|
|
16
16
|
import { coinsCommand } from './commands/coins.js';
|
|
17
17
|
import { auditSubmitCommand, auditListCommand, auditViewCommand } from './commands/audit.js';
|
|
18
18
|
import { planSubmitCommand, planListCommand } from './commands/plan.js';
|
|
19
|
+
import { trackCommand } from './utils/analytics.js';
|
|
19
20
|
// Read version from package.json
|
|
20
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
22
|
const __dirname = dirname(__filename);
|
|
@@ -46,6 +47,20 @@ ${chalk.dim(' /melt-help Answer questions about the development pla
|
|
|
46
47
|
`)
|
|
47
48
|
.hook('preAction', async () => {
|
|
48
49
|
await checkAndEnforceUpdate();
|
|
50
|
+
})
|
|
51
|
+
.hook('postAction', (_thisCommand, actionCommand) => {
|
|
52
|
+
try {
|
|
53
|
+
const parts = [];
|
|
54
|
+
let cmd = actionCommand;
|
|
55
|
+
while (cmd && cmd.name() !== 'meltctl') {
|
|
56
|
+
parts.unshift(cmd.name());
|
|
57
|
+
cmd = cmd.parent;
|
|
58
|
+
}
|
|
59
|
+
trackCommand(parts.join(' '), true);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Analytics must never break the CLI
|
|
63
|
+
}
|
|
49
64
|
});
|
|
50
65
|
program
|
|
51
66
|
.command('login')
|
|
@@ -185,6 +200,11 @@ program
|
|
|
185
200
|
});
|
|
186
201
|
program.parseAsync(process.argv).catch((error) => {
|
|
187
202
|
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
203
|
+
const commandName = process.argv
|
|
204
|
+
.slice(2)
|
|
205
|
+
.filter(a => !a.startsWith('-'))
|
|
206
|
+
.join(' ') || 'unknown';
|
|
207
|
+
trackCommand(commandName, false, message);
|
|
188
208
|
console.error(chalk.red(message));
|
|
189
209
|
process.exit(1);
|
|
190
210
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function trackCommand(command: string, success: boolean, errorMessage?: string): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { getStoredAuth, API_BASE } from './auth.js';
|
|
5
|
+
import { getGitRepository, getGitBranch, getProjectName } from './git.js';
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
function getVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
|
|
11
|
+
return pkg.version ?? 'unknown';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return 'unknown';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function trackCommand(command, success, errorMessage) {
|
|
18
|
+
try {
|
|
19
|
+
const auth = await getStoredAuth();
|
|
20
|
+
if (!auth || new Date(auth.expiresAt) <= new Date()) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const repo = getGitRepository();
|
|
24
|
+
fetch(`${API_BASE}/events`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${auth.token}`,
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
command,
|
|
32
|
+
project: getProjectName(),
|
|
33
|
+
repository: repo?.slug ?? null,
|
|
34
|
+
branch: getGitBranch(),
|
|
35
|
+
version: getVersion(),
|
|
36
|
+
success,
|
|
37
|
+
errorMessage: errorMessage?.slice(0, 500) ?? null,
|
|
38
|
+
}),
|
|
39
|
+
}).catch(() => { });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Analytics must never break the CLI
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('./auth.js', () => ({
|
|
3
|
+
getStoredAuth: vi.fn(),
|
|
4
|
+
API_BASE: 'https://api.test',
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('./git.js', () => ({
|
|
7
|
+
getGitRepository: vi
|
|
8
|
+
.fn()
|
|
9
|
+
.mockReturnValue({ slug: 'org/repo', url: 'https://github.com/org/repo' }),
|
|
10
|
+
getGitBranch: vi.fn().mockReturnValue('main'),
|
|
11
|
+
getProjectName: vi.fn().mockReturnValue('test-project'),
|
|
12
|
+
}));
|
|
13
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
14
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
15
|
+
import { getStoredAuth } from './auth.js';
|
|
16
|
+
import { trackCommand } from './analytics.js';
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
describe('trackCommand', () => {
|
|
21
|
+
it('sends event when authenticated', async () => {
|
|
22
|
+
vi.mocked(getStoredAuth).mockResolvedValue({
|
|
23
|
+
token: 'test-token',
|
|
24
|
+
email: 'user@meltstudio.co',
|
|
25
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
26
|
+
});
|
|
27
|
+
await trackCommand('standup', true);
|
|
28
|
+
expect(mockFetch).toHaveBeenCalledWith('https://api.test/events', expect.objectContaining({
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: expect.objectContaining({
|
|
31
|
+
Authorization: 'Bearer test-token',
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
35
|
+
expect(body.command).toBe('standup');
|
|
36
|
+
expect(body.success).toBe(true);
|
|
37
|
+
expect(body.project).toBe('test-project');
|
|
38
|
+
expect(body.repository).toBe('org/repo');
|
|
39
|
+
expect(body.branch).toBe('main');
|
|
40
|
+
});
|
|
41
|
+
it('does not send event when not authenticated', async () => {
|
|
42
|
+
vi.mocked(getStoredAuth).mockResolvedValue(undefined);
|
|
43
|
+
await trackCommand('standup', true);
|
|
44
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
it('does not send event when token is expired', async () => {
|
|
47
|
+
vi.mocked(getStoredAuth).mockResolvedValue({
|
|
48
|
+
token: 'test-token',
|
|
49
|
+
email: 'user@meltstudio.co',
|
|
50
|
+
expiresAt: new Date(Date.now() - 86400000).toISOString(),
|
|
51
|
+
});
|
|
52
|
+
await trackCommand('standup', true);
|
|
53
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it('does not throw when fetch fails', async () => {
|
|
56
|
+
vi.mocked(getStoredAuth).mockResolvedValue({
|
|
57
|
+
token: 'test-token',
|
|
58
|
+
email: 'user@meltstudio.co',
|
|
59
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
60
|
+
});
|
|
61
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
62
|
+
await expect(trackCommand('standup', true)).resolves.not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
it('does not throw when auth check fails', async () => {
|
|
65
|
+
vi.mocked(getStoredAuth).mockRejectedValue(new Error('fs error'));
|
|
66
|
+
await expect(trackCommand('standup', true)).resolves.not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
it('truncates error messages to 500 chars', async () => {
|
|
69
|
+
vi.mocked(getStoredAuth).mockResolvedValue({
|
|
70
|
+
token: 'test-token',
|
|
71
|
+
email: 'user@meltstudio.co',
|
|
72
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
73
|
+
});
|
|
74
|
+
const longError = 'x'.repeat(1000);
|
|
75
|
+
await trackCommand('standup', false, longError);
|
|
76
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
77
|
+
expect(body.errorMessage.length).toBe(500);
|
|
78
|
+
});
|
|
79
|
+
it('includes success false and error message on failure', async () => {
|
|
80
|
+
vi.mocked(getStoredAuth).mockResolvedValue({
|
|
81
|
+
token: 'test-token',
|
|
82
|
+
email: 'user@meltstudio.co',
|
|
83
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
84
|
+
});
|
|
85
|
+
await trackCommand('audit submit', false, 'Not authenticated');
|
|
86
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
87
|
+
expect(body.command).toBe('audit submit');
|
|
88
|
+
expect(body.success).toBe(false);
|
|
89
|
+
expect(body.errorMessage).toBe('Not authenticated');
|
|
90
|
+
});
|
|
91
|
+
});
|
package/package.json
CHANGED