@meltstudio/meltctl 4.33.2 → 4.35.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.
@@ -0,0 +1,2 @@
1
+ export declare function detectPackageManager(): 'npm' | 'yarn' | 'unknown';
2
+ export declare function updateCommand(): Promise<void>;
@@ -0,0 +1,74 @@
1
+ import chalk from 'chalk';
2
+ import { execSync } from 'child_process';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { getCurrentCliVersion, getLatestCliVersion, getUpdateSeverity, } from '../utils/version-check.js';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ export function detectPackageManager() {
8
+ const installPath = path.resolve(__dirname, '../..');
9
+ try {
10
+ const npmGlobalPrefix = execSync('npm prefix -g', { encoding: 'utf-8', stdio: 'pipe' }).trim();
11
+ if (installPath.startsWith(npmGlobalPrefix)) {
12
+ return 'npm';
13
+ }
14
+ }
15
+ catch {
16
+ // npm not available
17
+ }
18
+ try {
19
+ const yarnGlobalDir = execSync('yarn global dir', { encoding: 'utf-8', stdio: 'pipe' }).trim();
20
+ if (installPath.startsWith(yarnGlobalDir)) {
21
+ return 'yarn';
22
+ }
23
+ }
24
+ catch {
25
+ // yarn not available
26
+ }
27
+ // Check path heuristics as fallback
28
+ if (installPath.includes('yarn'))
29
+ return 'yarn';
30
+ if (installPath.includes('npm'))
31
+ return 'npm';
32
+ return 'unknown';
33
+ }
34
+ export async function updateCommand() {
35
+ const currentVersion = await getCurrentCliVersion();
36
+ const latestVersion = await getLatestCliVersion();
37
+ if (!latestVersion) {
38
+ console.error(chalk.red('Could not check for updates. Check your network connection.'));
39
+ process.exit(1);
40
+ }
41
+ const severity = getUpdateSeverity(currentVersion, latestVersion);
42
+ if (severity === 'none') {
43
+ console.log(chalk.green(` ✓ Already on the latest version (${currentVersion})`));
44
+ return;
45
+ }
46
+ console.log(chalk.dim(` ${currentVersion} → ${latestVersion}`));
47
+ console.log();
48
+ const pm = detectPackageManager();
49
+ let cmd;
50
+ if (pm === 'yarn') {
51
+ cmd = 'yarn global add @meltstudio/meltctl@latest';
52
+ }
53
+ else if (pm === 'npm') {
54
+ cmd = 'npm install -g @meltstudio/meltctl@latest';
55
+ }
56
+ else {
57
+ // Unknown install method — try npm as default
58
+ console.log(chalk.dim(' Could not detect package manager, trying npm...'));
59
+ cmd = 'npm install -g @meltstudio/meltctl@latest';
60
+ }
61
+ console.log(chalk.dim(` Running: ${cmd}`));
62
+ console.log();
63
+ try {
64
+ execSync(cmd, { stdio: 'inherit' });
65
+ console.log();
66
+ console.log(chalk.green(` ✓ Updated to ${latestVersion}`));
67
+ }
68
+ catch {
69
+ console.error();
70
+ console.error(chalk.red(' Update failed. Try running manually:'));
71
+ console.error(chalk.cyan(` ${cmd}`));
72
+ process.exit(1);
73
+ }
74
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('child_process', () => ({
3
+ execSync: vi.fn(),
4
+ }));
5
+ vi.mock('fs-extra', () => ({
6
+ default: {
7
+ readJson: vi.fn(),
8
+ },
9
+ }));
10
+ vi.mock('../utils/version-check.js', () => ({
11
+ getCurrentCliVersion: vi.fn(),
12
+ getLatestCliVersion: vi.fn(),
13
+ getUpdateSeverity: vi.fn(),
14
+ }));
15
+ import { execSync } from 'child_process';
16
+ import { getCurrentCliVersion, getLatestCliVersion, getUpdateSeverity, } from '../utils/version-check.js';
17
+ import { updateCommand, detectPackageManager } from './update.js';
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ vi.spyOn(console, 'log').mockImplementation(() => { });
21
+ vi.spyOn(console, 'error').mockImplementation(() => { });
22
+ vi.spyOn(process, 'exit').mockImplementation(() => undefined);
23
+ });
24
+ describe('detectPackageManager', () => {
25
+ it('returns npm when install path matches npm prefix', () => {
26
+ ;
27
+ execSync.mockImplementation((cmd) => {
28
+ if (cmd === 'npm prefix -g')
29
+ return '/usr/local/lib\n';
30
+ throw new Error('not found');
31
+ });
32
+ // This test depends on the actual install path, so just verify it returns a valid value
33
+ const result = detectPackageManager();
34
+ expect(['npm', 'yarn', 'unknown']).toContain(result);
35
+ });
36
+ it('returns unknown when neither npm nor yarn detected', () => {
37
+ ;
38
+ execSync.mockImplementation(() => {
39
+ throw new Error('not found');
40
+ });
41
+ const result = detectPackageManager();
42
+ expect(['npm', 'yarn', 'unknown']).toContain(result);
43
+ });
44
+ });
45
+ describe('updateCommand', () => {
46
+ it('prints already up to date when on latest', async () => {
47
+ vi.mocked(getCurrentCliVersion).mockResolvedValue('4.34.0');
48
+ vi.mocked(getLatestCliVersion).mockResolvedValue('4.34.0');
49
+ vi.mocked(getUpdateSeverity).mockReturnValue('none');
50
+ await updateCommand();
51
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
52
+ expect(logCalls.some((msg) => msg.includes('Already on the latest'))).toBe(true);
53
+ expect(process.exit).not.toHaveBeenCalled();
54
+ });
55
+ it('exits when latest version cannot be fetched', async () => {
56
+ vi.mocked(getCurrentCliVersion).mockResolvedValue('4.34.0');
57
+ vi.mocked(getLatestCliVersion).mockResolvedValue(null);
58
+ await updateCommand();
59
+ expect(process.exit).toHaveBeenCalledWith(1);
60
+ });
61
+ it('runs npm install when update available and npm detected', async () => {
62
+ vi.mocked(getCurrentCliVersion).mockResolvedValue('4.33.0');
63
+ vi.mocked(getLatestCliVersion).mockResolvedValue('4.34.0');
64
+ vi.mocked(getUpdateSeverity).mockReturnValue('minor');
65
+ execSync.mockImplementation((cmd) => {
66
+ if (cmd === 'npm prefix -g')
67
+ return '/usr/local/lib\n';
68
+ if (cmd.startsWith('npm install -g'))
69
+ return '';
70
+ throw new Error('not found');
71
+ });
72
+ await updateCommand();
73
+ expect(execSync).toHaveBeenCalledWith('npm install -g @meltstudio/meltctl@latest', {
74
+ stdio: 'inherit',
75
+ });
76
+ });
77
+ it('exits with error when install command fails', async () => {
78
+ vi.mocked(getCurrentCliVersion).mockResolvedValue('4.33.0');
79
+ vi.mocked(getLatestCliVersion).mockResolvedValue('4.34.0');
80
+ vi.mocked(getUpdateSeverity).mockReturnValue('minor');
81
+ execSync.mockImplementation((cmd) => {
82
+ if (cmd === 'npm prefix -g')
83
+ return '/usr/local/lib\n';
84
+ if (cmd.startsWith('npm install -g'))
85
+ throw new Error('permission denied');
86
+ throw new Error('not found');
87
+ });
88
+ await updateCommand();
89
+ expect(process.exit).toHaveBeenCalledWith(1);
90
+ const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
91
+ expect(errorCalls.some((msg) => msg.includes('Update failed'))).toBe(true);
92
+ });
93
+ });
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 { updateCommand } from './commands/update.js';
19
20
  import { trackCommand } from './utils/analytics.js';
20
21
  // Read version from package.json
21
22
  const __filename = fileURLToPath(import.meta.url);
@@ -186,6 +187,12 @@ plan
186
187
  .action(async (options) => {
187
188
  await planListCommand(options);
188
189
  });
190
+ program
191
+ .command('update')
192
+ .description('update meltctl to the latest version')
193
+ .action(async () => {
194
+ await updateCommand();
195
+ });
189
196
  program
190
197
  .command('version')
191
198
  .description('show current version')
@@ -2,4 +2,6 @@ export declare function getCurrentCliVersion(): Promise<string>;
2
2
  export declare function getLatestCliVersion(): Promise<string | null>;
3
3
  export declare function compareVersions(current: string, latest: string): boolean;
4
4
  export declare function isCI(): boolean;
5
+ export type UpdateSeverity = 'none' | 'patch' | 'minor' | 'major';
6
+ export declare function getUpdateSeverity(current: string, latest: string): UpdateSeverity;
5
7
  export declare function checkAndEnforceUpdate(): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { confirm } from '@inquirer/prompts';
2
3
  import { execSync } from 'child_process';
3
4
  import fs from 'fs-extra';
4
5
  import path from 'path';
@@ -72,6 +73,19 @@ export function isCI() {
72
73
  process.env.MELTCTL_SKIP_UPDATE_CHECK // Custom override
73
74
  );
74
75
  }
76
+ export function getUpdateSeverity(current, latest) {
77
+ const parseCurrent = current.split('-')[0].split('.').map(Number);
78
+ const parseLatest = latest.split('-')[0].split('.').map(Number);
79
+ const [curMajor = 0, curMinor = 0, curPatch = 0] = parseCurrent;
80
+ const [latMajor = 0, latMinor = 0, latPatch = 0] = parseLatest;
81
+ if (latMajor > curMajor)
82
+ return 'major';
83
+ if (latMajor === curMajor && latMinor > curMinor)
84
+ return 'minor';
85
+ if (latMajor === curMajor && latMinor === curMinor && latPatch > curPatch)
86
+ return 'patch';
87
+ return 'none';
88
+ }
75
89
  export async function checkAndEnforceUpdate() {
76
90
  // Skip update check in CI environments
77
91
  if (isCI()) {
@@ -84,27 +98,38 @@ export async function checkAndEnforceUpdate() {
84
98
  if (!latestVersion) {
85
99
  return;
86
100
  }
87
- // Check if update is needed
88
- if (compareVersions(currentVersion, latestVersion)) {
89
- console.log();
90
- console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
91
- console.log(chalk.red.bold(' ⚠️ Update Required'));
92
- console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
93
- console.log();
94
- console.log(chalk.yellow(`Current version: ${currentVersion}`));
95
- console.log(chalk.green(`Latest version: ${latestVersion}`));
96
- console.log();
97
- console.log(chalk.white('Please update meltctl to continue:'));
101
+ const severity = getUpdateSeverity(currentVersion, latestVersion);
102
+ if (severity === 'none') {
103
+ return;
104
+ }
105
+ if (severity === 'patch') {
106
+ // Patch updates: warn but allow continuing
98
107
  console.log();
99
- console.log(chalk.cyan(' npm install -g @meltstudio/meltctl@latest'));
108
+ console.log(chalk.yellow(` Update available: ${currentVersion} → ${latestVersion} (run: meltctl update)`));
100
109
  console.log();
101
- console.log(chalk.gray('Or with yarn:'));
102
- console.log(chalk.cyan(' yarn global add @meltstudio/meltctl@latest'));
110
+ return;
111
+ }
112
+ // Minor/major updates: offer to update, block if declined
113
+ console.log();
114
+ console.log(chalk.yellow(` Update required: ${currentVersion} → ${latestVersion}`));
115
+ console.log();
116
+ const shouldUpdate = await confirm({
117
+ message: 'Update now?',
118
+ default: true,
119
+ });
120
+ if (shouldUpdate) {
121
+ const { updateCommand } = await import('../commands/update.js');
122
+ await updateCommand();
123
+ // Re-run the original command after update
103
124
  console.log();
104
- console.log(chalk.gray('To skip this check (CI/CD), set MELTCTL_SKIP_UPDATE_CHECK=1'));
125
+ console.log(chalk.dim(' Please re-run your command.'));
105
126
  console.log();
106
- process.exit(1);
127
+ process.exit(0);
107
128
  }
129
+ console.log();
130
+ console.log(chalk.gray('To skip this check (CI/CD), set MELTCTL_SKIP_UPDATE_CHECK=1'));
131
+ console.log();
132
+ process.exit(1);
108
133
  }
109
134
  catch {
110
135
  // If any error occurs during version check, allow continuing
@@ -7,9 +7,16 @@ vi.mock('fs-extra', () => ({
7
7
  readJson: vi.fn(),
8
8
  },
9
9
  }));
10
+ vi.mock('@inquirer/prompts', () => ({
11
+ confirm: vi.fn(),
12
+ }));
13
+ vi.mock('../commands/update.js', () => ({
14
+ updateCommand: vi.fn().mockResolvedValue(undefined),
15
+ }));
10
16
  import { execSync } from 'child_process';
11
17
  import fs from 'fs-extra';
12
- import { getCurrentCliVersion, getLatestCliVersion, compareVersions, isCI, checkAndEnforceUpdate, } from './version-check.js';
18
+ import { confirm } from '@inquirer/prompts';
19
+ import { getCurrentCliVersion, getLatestCliVersion, compareVersions, getUpdateSeverity, isCI, checkAndEnforceUpdate, } from './version-check.js';
13
20
  beforeEach(() => {
14
21
  vi.clearAllMocks();
15
22
  vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -96,6 +103,26 @@ describe('isCI', () => {
96
103
  expect(isCI()).toBe(true);
97
104
  });
98
105
  });
106
+ describe('getUpdateSeverity', () => {
107
+ it('returns major when major version is higher', () => {
108
+ expect(getUpdateSeverity('1.0.0', '2.0.0')).toBe('major');
109
+ });
110
+ it('returns minor when minor version is higher', () => {
111
+ expect(getUpdateSeverity('1.0.0', '1.1.0')).toBe('minor');
112
+ });
113
+ it('returns patch when only patch is higher', () => {
114
+ expect(getUpdateSeverity('1.0.0', '1.0.1')).toBe('patch');
115
+ });
116
+ it('returns none when versions are equal', () => {
117
+ expect(getUpdateSeverity('1.0.0', '1.0.0')).toBe('none');
118
+ });
119
+ it('returns none when current is newer', () => {
120
+ expect(getUpdateSeverity('2.0.0', '1.0.0')).toBe('none');
121
+ });
122
+ it('returns major over minor', () => {
123
+ expect(getUpdateSeverity('1.5.3', '2.0.0')).toBe('major');
124
+ });
125
+ });
99
126
  describe('checkAndEnforceUpdate', () => {
100
127
  it('skips check in CI environment', async () => {
101
128
  process.env.CI = 'true';
@@ -119,13 +146,40 @@ describe('checkAndEnforceUpdate', () => {
119
146
  await checkAndEnforceUpdate();
120
147
  // Should not throw or exit
121
148
  });
122
- it('calls process.exit when update is required', async () => {
149
+ it('exits when user declines update on minor bump', async () => {
123
150
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
124
151
  fs.readJson.mockResolvedValue({ version: '4.25.0' });
125
152
  execSync.mockReturnValue('"4.26.0"\n');
153
+ vi.mocked(confirm).mockResolvedValue(false);
126
154
  await checkAndEnforceUpdate();
155
+ expect(confirm).toHaveBeenCalled();
127
156
  expect(exitSpy).toHaveBeenCalledWith(1);
128
157
  });
158
+ it('runs update when user accepts on minor bump', async () => {
159
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
160
+ fs.readJson.mockResolvedValue({ version: '4.25.0' });
161
+ execSync.mockReturnValue('"4.26.0"\n');
162
+ vi.mocked(confirm).mockResolvedValue(true);
163
+ await checkAndEnforceUpdate();
164
+ expect(exitSpy).toHaveBeenCalledWith(0);
165
+ });
166
+ it('exits when user declines update on major bump', async () => {
167
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
168
+ fs.readJson.mockResolvedValue({ version: '4.33.0' });
169
+ execSync.mockReturnValue('"5.0.0"\n');
170
+ vi.mocked(confirm).mockResolvedValue(false);
171
+ await checkAndEnforceUpdate();
172
+ expect(exitSpy).toHaveBeenCalledWith(1);
173
+ });
174
+ it('warns but does not block on patch update', async () => {
175
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
176
+ fs.readJson.mockResolvedValue({ version: '4.33.0' });
177
+ execSync.mockReturnValue('"4.33.2"\n');
178
+ await checkAndEnforceUpdate();
179
+ expect(exitSpy).not.toHaveBeenCalled();
180
+ const logCalls = console.log.mock.calls.map((c) => String(c[0]));
181
+ expect(logCalls.some((msg) => msg.includes('Update available'))).toBe(true);
182
+ });
129
183
  it('allows continuing when getCurrentCliVersion throws', async () => {
130
184
  ;
131
185
  fs.readJson.mockRejectedValue(new Error('file not found'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/meltctl",
3
- "version": "4.33.2",
3
+ "version": "4.35.0",
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",