@meltstudio/meltctl 4.32.0 → 4.33.1

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', async (_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
+ await 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,48 @@
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
+ const controller = new AbortController();
25
+ const timeout = setTimeout(() => controller.abort(), 3000);
26
+ await fetch(`${API_BASE}/events`, {
27
+ method: 'POST',
28
+ headers: {
29
+ Authorization: `Bearer ${auth.token}`,
30
+ 'Content-Type': 'application/json',
31
+ },
32
+ body: JSON.stringify({
33
+ command,
34
+ project: getProjectName(),
35
+ repository: repo?.slug ?? null,
36
+ branch: getGitBranch(),
37
+ version: getVersion(),
38
+ success,
39
+ errorMessage: errorMessage?.slice(0, 500) ?? null,
40
+ }),
41
+ signal: controller.signal,
42
+ }).catch(() => { });
43
+ clearTimeout(timeout);
44
+ }
45
+ catch {
46
+ // Analytics must never break the CLI
47
+ }
48
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/meltctl",
3
- "version": "4.32.0",
3
+ "version": "4.33.1",
4
4
  "description": "AI-first development tools for teams - set up AGENTS.md, Claude Code, Cursor, and OpenCode standards",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",