linchpin-cli 0.2.0 → 0.2.2
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/bin/vm.js +37 -1
- package/dist/src/__tests__/align.integration.test.js +209 -0
- package/dist/src/__tests__/check.integration.test.js +148 -0
- package/dist/src/commands/check.js +35 -0
- package/dist/src/commands/login.js +6 -0
- package/dist/src/commands/plan.js +152 -0
- package/dist/src/lib/sentry.js +116 -0
- package/dist/src/utils/logger.js +9 -1
- package/package.json +2 -1
package/dist/bin/vm.js
CHANGED
|
@@ -6,16 +6,32 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
8
8
|
dotenv_1.default.config(); // Load env vars once at entry point
|
|
9
|
+
// Read version from package.json
|
|
10
|
+
// Path: from bin/vm.ts -> ../package.json (source)
|
|
11
|
+
// Path: from dist/bin/vm.js -> ../../package.json (compiled)
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
const CLI_VERSION = pkg.version;
|
|
15
|
+
const sentry_1 = require("../src/lib/sentry");
|
|
16
|
+
const auth_1 = require("../src/lib/auth");
|
|
17
|
+
// Initialize Sentry early to catch all errors
|
|
18
|
+
(0, sentry_1.initSentry)(CLI_VERSION);
|
|
19
|
+
// Set user if already logged in (for error tracking)
|
|
20
|
+
const existingToken = (0, auth_1.loadCredentials)();
|
|
21
|
+
if (existingToken) {
|
|
22
|
+
(0, sentry_1.setUser)({ id: existingToken.substring(0, 16) });
|
|
23
|
+
}
|
|
9
24
|
const commander_1 = require("commander");
|
|
10
25
|
const check_1 = require("../src/commands/check");
|
|
11
26
|
const explain_1 = require("../src/commands/explain");
|
|
12
27
|
const align_1 = require("../src/commands/align");
|
|
13
28
|
const login_1 = require("../src/commands/login");
|
|
29
|
+
const plan_1 = require("../src/commands/plan");
|
|
14
30
|
const program = new commander_1.Command();
|
|
15
31
|
program
|
|
16
32
|
.name('linchpin')
|
|
17
33
|
.description("Linchpin: The 'Don't Break My App' Tool")
|
|
18
|
-
.version(
|
|
34
|
+
.version(CLI_VERSION);
|
|
19
35
|
program
|
|
20
36
|
.command('check', { isDefault: true })
|
|
21
37
|
.description('Scan your project for outdated dependencies')
|
|
@@ -44,4 +60,24 @@ program
|
|
|
44
60
|
.command('whoami')
|
|
45
61
|
.description('Show current login status')
|
|
46
62
|
.action(() => (0, login_1.whoamiCommand)());
|
|
63
|
+
program
|
|
64
|
+
.command('plan <file>')
|
|
65
|
+
.description('Execute an upgrade plan from a JSON file')
|
|
66
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
67
|
+
.option('--dry-run', 'Show what would be done without making changes')
|
|
68
|
+
.action((file, options) => (0, plan_1.planCommand)(file, options));
|
|
69
|
+
// Global error handler to capture unhandled errors
|
|
70
|
+
process.on('uncaughtException', async (error) => {
|
|
71
|
+
(0, sentry_1.captureException)(error, { type: 'uncaughtException' });
|
|
72
|
+
console.error('An unexpected error occurred:', error.message);
|
|
73
|
+
await (0, sentry_1.flush)();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
process.on('unhandledRejection', async (reason) => {
|
|
77
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
78
|
+
(0, sentry_1.captureException)(error, { type: 'unhandledRejection' });
|
|
79
|
+
console.error('An unexpected error occurred:', error.message);
|
|
80
|
+
await (0, sentry_1.flush)();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
|
47
83
|
program.parse(process.argv);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const align_1 = require("../commands/align");
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
jest.mock('../lib/files', () => ({
|
|
9
|
+
getPackageJson: jest.fn(),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock('../lib/npm', () => ({
|
|
12
|
+
getRegistryVersion: jest.fn(),
|
|
13
|
+
hasApiKey: jest.fn(),
|
|
14
|
+
}));
|
|
15
|
+
jest.mock('../lib/ai', () => ({
|
|
16
|
+
getLatestVersion: jest.fn(),
|
|
17
|
+
getUpgradeRisks: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('child_process', () => ({
|
|
20
|
+
execSync: jest.fn(),
|
|
21
|
+
}));
|
|
22
|
+
jest.mock('inquirer', () => ({
|
|
23
|
+
prompt: jest.fn(),
|
|
24
|
+
}));
|
|
25
|
+
// Mock chalk (uses __mocks__/chalk.js)
|
|
26
|
+
jest.mock('chalk');
|
|
27
|
+
const files_1 = require("../lib/files");
|
|
28
|
+
const npm_1 = require("../lib/npm");
|
|
29
|
+
const child_process_1 = require("child_process");
|
|
30
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
31
|
+
const mockGetPackageJson = files_1.getPackageJson;
|
|
32
|
+
const mockGetRegistryVersion = npm_1.getRegistryVersion;
|
|
33
|
+
const mockHasApiKey = npm_1.hasApiKey;
|
|
34
|
+
const mockExecSync = child_process_1.execSync;
|
|
35
|
+
const mockPrompt = inquirer_1.default.prompt;
|
|
36
|
+
describe('alignCommand integration', () => {
|
|
37
|
+
let consoleSpy;
|
|
38
|
+
let stdoutSpy;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
42
|
+
stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation();
|
|
43
|
+
// Default mocks
|
|
44
|
+
mockHasApiKey.mockReturnValue(false);
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
consoleSpy.mockRestore();
|
|
48
|
+
stdoutSpy.mockRestore();
|
|
49
|
+
});
|
|
50
|
+
describe('single package alignment', () => {
|
|
51
|
+
it('shows error when package.json is missing', async () => {
|
|
52
|
+
mockGetPackageJson.mockResolvedValue(null);
|
|
53
|
+
await (0, align_1.alignCommand)('express');
|
|
54
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json found'));
|
|
55
|
+
});
|
|
56
|
+
it('shows error when package is not found', async () => {
|
|
57
|
+
mockGetPackageJson.mockResolvedValue({
|
|
58
|
+
name: 'test-project',
|
|
59
|
+
version: '1.0.0',
|
|
60
|
+
dependencies: { 'lodash': '4.17.0' },
|
|
61
|
+
});
|
|
62
|
+
await (0, align_1.alignCommand)('nonexistent-pkg');
|
|
63
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
|
64
|
+
});
|
|
65
|
+
it('shows up-to-date message when no update needed', async () => {
|
|
66
|
+
mockGetPackageJson.mockResolvedValue({
|
|
67
|
+
name: 'test-project',
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
dependencies: { 'express': '4.18.2' },
|
|
70
|
+
});
|
|
71
|
+
mockGetRegistryVersion.mockReturnValue('4.18.2');
|
|
72
|
+
await (0, align_1.alignCommand)('express');
|
|
73
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Already up to date'));
|
|
74
|
+
});
|
|
75
|
+
it('prompts for confirmation on safe upgrade', async () => {
|
|
76
|
+
mockGetPackageJson.mockResolvedValue({
|
|
77
|
+
name: 'test-project',
|
|
78
|
+
version: '1.0.0',
|
|
79
|
+
dependencies: { 'express': '^4.17.0' },
|
|
80
|
+
});
|
|
81
|
+
mockGetRegistryVersion.mockReturnValue('4.18.2');
|
|
82
|
+
mockPrompt.mockResolvedValue({ confirm: false });
|
|
83
|
+
// Mock git check
|
|
84
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
85
|
+
if (cmd.includes('rev-parse'))
|
|
86
|
+
throw new Error('not a git repo');
|
|
87
|
+
return Buffer.from('');
|
|
88
|
+
});
|
|
89
|
+
await (0, align_1.alignCommand)('express');
|
|
90
|
+
// Should show current and target versions
|
|
91
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Current'));
|
|
92
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Target'));
|
|
93
|
+
// Should prompt for confirmation
|
|
94
|
+
expect(mockPrompt).toHaveBeenCalled();
|
|
95
|
+
// User declined, should show blocked message
|
|
96
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('blocked'));
|
|
97
|
+
});
|
|
98
|
+
it('installs package when user confirms', async () => {
|
|
99
|
+
mockGetPackageJson.mockResolvedValue({
|
|
100
|
+
name: 'test-project',
|
|
101
|
+
version: '1.0.0',
|
|
102
|
+
dependencies: { 'express': '^4.17.0' },
|
|
103
|
+
});
|
|
104
|
+
mockGetRegistryVersion.mockReturnValue('4.18.2');
|
|
105
|
+
mockPrompt.mockResolvedValue({ confirm: true });
|
|
106
|
+
// Mock git and npm commands
|
|
107
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
108
|
+
if (cmd.includes('rev-parse'))
|
|
109
|
+
throw new Error('not a git repo');
|
|
110
|
+
return Buffer.from('');
|
|
111
|
+
});
|
|
112
|
+
await (0, align_1.alignCommand)('express');
|
|
113
|
+
// Should run npm install
|
|
114
|
+
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('npm install express@4.18.2'), expect.anything());
|
|
115
|
+
// Should show success
|
|
116
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Success'));
|
|
117
|
+
});
|
|
118
|
+
it('warns about major version upgrades', async () => {
|
|
119
|
+
mockGetPackageJson.mockResolvedValue({
|
|
120
|
+
name: 'test-project',
|
|
121
|
+
version: '1.0.0',
|
|
122
|
+
dependencies: { 'express': '^3.0.0' },
|
|
123
|
+
});
|
|
124
|
+
mockGetRegistryVersion.mockReturnValue('4.18.2');
|
|
125
|
+
mockPrompt.mockResolvedValue({ confirm: false });
|
|
126
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
127
|
+
if (cmd.includes('rev-parse'))
|
|
128
|
+
throw new Error('not a git repo');
|
|
129
|
+
return Buffer.from('');
|
|
130
|
+
});
|
|
131
|
+
await (0, align_1.alignCommand)('express');
|
|
132
|
+
// Should show major version warning
|
|
133
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('MAJOR VERSION JUMP'));
|
|
134
|
+
});
|
|
135
|
+
it('creates git restore point when in git repo', async () => {
|
|
136
|
+
mockGetPackageJson.mockResolvedValue({
|
|
137
|
+
name: 'test-project',
|
|
138
|
+
version: '1.0.0',
|
|
139
|
+
dependencies: { 'express': '^4.17.0' },
|
|
140
|
+
});
|
|
141
|
+
mockGetRegistryVersion.mockReturnValue('4.18.2');
|
|
142
|
+
mockPrompt.mockResolvedValue({ confirm: true });
|
|
143
|
+
// Mock as git repo
|
|
144
|
+
mockExecSync.mockImplementation(() => Buffer.from(''));
|
|
145
|
+
await (0, align_1.alignCommand)('express');
|
|
146
|
+
// Should create backup commit
|
|
147
|
+
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining('git commit'), expect.anything());
|
|
148
|
+
// Should show restore hint
|
|
149
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('restore point'));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe('align --all mode', () => {
|
|
153
|
+
it('shows up-to-date when no updates available', async () => {
|
|
154
|
+
mockGetPackageJson.mockResolvedValue({
|
|
155
|
+
name: 'test-project',
|
|
156
|
+
version: '1.0.0',
|
|
157
|
+
dependencies: {
|
|
158
|
+
'express': '4.18.2',
|
|
159
|
+
'lodash': '4.17.21',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
mockGetRegistryVersion
|
|
163
|
+
.mockReturnValueOnce('4.18.2')
|
|
164
|
+
.mockReturnValueOnce('4.17.21');
|
|
165
|
+
await (0, align_1.alignCommand)({ all: true });
|
|
166
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('All packages are up to date'));
|
|
167
|
+
});
|
|
168
|
+
it('allows user to select packages for update', async () => {
|
|
169
|
+
mockGetPackageJson.mockResolvedValue({
|
|
170
|
+
name: 'test-project',
|
|
171
|
+
version: '1.0.0',
|
|
172
|
+
dependencies: {
|
|
173
|
+
'express': '4.17.0',
|
|
174
|
+
'lodash': '4.17.0',
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
mockGetRegistryVersion
|
|
178
|
+
.mockReturnValueOnce('4.18.2') // express - minor
|
|
179
|
+
.mockReturnValueOnce('4.17.21'); // lodash - patch
|
|
180
|
+
mockPrompt.mockResolvedValue({ selected: [] });
|
|
181
|
+
mockExecSync.mockImplementation((cmd) => {
|
|
182
|
+
if (cmd.includes('rev-parse'))
|
|
183
|
+
throw new Error('not a git repo');
|
|
184
|
+
return Buffer.from('');
|
|
185
|
+
});
|
|
186
|
+
await (0, align_1.alignCommand)({ all: true });
|
|
187
|
+
// Should show found updates
|
|
188
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 packages with updates'));
|
|
189
|
+
// User selected none
|
|
190
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No packages selected'));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('isMajorUpgrade', () => {
|
|
195
|
+
it('detects major version changes', () => {
|
|
196
|
+
expect((0, align_1.isMajorUpgrade)('1.0.0', '2.0.0')).toBe(true);
|
|
197
|
+
expect((0, align_1.isMajorUpgrade)('4.17.21', '5.0.0')).toBe(true);
|
|
198
|
+
expect((0, align_1.isMajorUpgrade)('^3.0.0', '4.0.0')).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
it('returns false for minor/patch changes', () => {
|
|
201
|
+
expect((0, align_1.isMajorUpgrade)('1.0.0', '1.1.0')).toBe(false);
|
|
202
|
+
expect((0, align_1.isMajorUpgrade)('1.0.0', '1.0.1')).toBe(false);
|
|
203
|
+
expect((0, align_1.isMajorUpgrade)('^4.17.0', '4.18.2')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
it('handles edge cases', () => {
|
|
206
|
+
expect((0, align_1.isMajorUpgrade)('0.9.0', '1.0.0')).toBe(true);
|
|
207
|
+
expect((0, align_1.isMajorUpgrade)('~1.0.0', '2.0.0')).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const check_1 = require("../commands/check");
|
|
4
|
+
// Mock dependencies
|
|
5
|
+
jest.mock('../lib/files', () => ({
|
|
6
|
+
getPackageJson: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
jest.mock('../lib/npm', () => ({
|
|
9
|
+
getRegistryVersion: jest.fn(),
|
|
10
|
+
hasApiKey: jest.fn(),
|
|
11
|
+
isUsingCloudApi: jest.fn(),
|
|
12
|
+
apiRequest: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
jest.mock('../lib/ai', () => ({
|
|
15
|
+
getLatestVersion: jest.fn(),
|
|
16
|
+
getBatchRiskAnalysis: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
// Mock chalk (uses __mocks__/chalk.js)
|
|
19
|
+
jest.mock('chalk');
|
|
20
|
+
const files_1 = require("../lib/files");
|
|
21
|
+
const npm_1 = require("../lib/npm");
|
|
22
|
+
const mockGetPackageJson = files_1.getPackageJson;
|
|
23
|
+
const mockGetRegistryVersion = npm_1.getRegistryVersion;
|
|
24
|
+
const mockHasApiKey = npm_1.hasApiKey;
|
|
25
|
+
const mockIsUsingCloudApi = npm_1.isUsingCloudApi;
|
|
26
|
+
describe('checkCommand integration', () => {
|
|
27
|
+
let consoleSpy;
|
|
28
|
+
let stdoutSpy;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
32
|
+
stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation();
|
|
33
|
+
// Default mocks
|
|
34
|
+
mockHasApiKey.mockReturnValue(false);
|
|
35
|
+
mockIsUsingCloudApi.mockReturnValue(false);
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
consoleSpy.mockRestore();
|
|
39
|
+
stdoutSpy.mockRestore();
|
|
40
|
+
});
|
|
41
|
+
describe('when package.json is missing', () => {
|
|
42
|
+
it('shows error message', async () => {
|
|
43
|
+
mockGetPackageJson.mockResolvedValue(null);
|
|
44
|
+
await (0, check_1.checkCommand)({});
|
|
45
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json found'));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('when package.json has no dependencies', () => {
|
|
49
|
+
it('shows warning message', async () => {
|
|
50
|
+
mockGetPackageJson.mockResolvedValue({
|
|
51
|
+
name: 'test-project',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
dependencies: {},
|
|
54
|
+
devDependencies: {},
|
|
55
|
+
});
|
|
56
|
+
await (0, check_1.checkCommand)({});
|
|
57
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('no dependencies listed'));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('when scanning dependencies (free mode)', () => {
|
|
61
|
+
it('checks all dependencies and displays results', async () => {
|
|
62
|
+
mockGetPackageJson.mockResolvedValue({
|
|
63
|
+
name: 'test-project',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
dependencies: {
|
|
66
|
+
'express': '^4.18.0',
|
|
67
|
+
'lodash': '^4.17.21',
|
|
68
|
+
},
|
|
69
|
+
devDependencies: {
|
|
70
|
+
'jest': '^29.0.0',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
mockGetRegistryVersion
|
|
74
|
+
.mockReturnValueOnce('4.21.0') // express - MINOR
|
|
75
|
+
.mockReturnValueOnce('4.17.21') // lodash - OK
|
|
76
|
+
.mockReturnValueOnce('30.0.0'); // jest - MAJOR
|
|
77
|
+
await (0, check_1.checkCommand)({});
|
|
78
|
+
// Should check each package
|
|
79
|
+
expect(mockGetRegistryVersion).toHaveBeenCalledTimes(3);
|
|
80
|
+
expect(mockGetRegistryVersion).toHaveBeenCalledWith('express');
|
|
81
|
+
expect(mockGetRegistryVersion).toHaveBeenCalledWith('lodash');
|
|
82
|
+
expect(mockGetRegistryVersion).toHaveBeenCalledWith('jest');
|
|
83
|
+
// Should display scanning message
|
|
84
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Checking 3 packages'));
|
|
85
|
+
// Should show free mode hint
|
|
86
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Free mode'));
|
|
87
|
+
});
|
|
88
|
+
it('counts upgrade types correctly', async () => {
|
|
89
|
+
mockGetPackageJson.mockResolvedValue({
|
|
90
|
+
name: 'test-project',
|
|
91
|
+
version: '1.0.0',
|
|
92
|
+
dependencies: {
|
|
93
|
+
'pkg-major': '1.0.0',
|
|
94
|
+
'pkg-minor': '1.0.0',
|
|
95
|
+
'pkg-patch': '1.0.0',
|
|
96
|
+
'pkg-ok': '1.0.0',
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
mockGetRegistryVersion
|
|
100
|
+
.mockReturnValueOnce('2.0.0') // MAJOR
|
|
101
|
+
.mockReturnValueOnce('1.1.0') // MINOR
|
|
102
|
+
.mockReturnValueOnce('1.0.1') // PATCH
|
|
103
|
+
.mockReturnValueOnce('1.0.0'); // OK
|
|
104
|
+
await (0, check_1.checkCommand)({});
|
|
105
|
+
// Should display summary with correct counts
|
|
106
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 major'));
|
|
107
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 minor'));
|
|
108
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 patch'));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('deep scan mode', () => {
|
|
112
|
+
it('requires API key for deep scan', async () => {
|
|
113
|
+
mockHasApiKey.mockReturnValue(false);
|
|
114
|
+
await (0, check_1.checkCommand)({ deep: true });
|
|
115
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deep scan requires an API key'));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('error handling', () => {
|
|
119
|
+
it('handles npm registry errors gracefully', async () => {
|
|
120
|
+
mockGetPackageJson.mockResolvedValue({
|
|
121
|
+
name: 'test-project',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
dependencies: {
|
|
124
|
+
'nonexistent-package': '1.0.0',
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
mockGetRegistryVersion.mockReturnValue('Unknown');
|
|
128
|
+
await (0, check_1.checkCommand)({});
|
|
129
|
+
// Should not throw, should display result
|
|
130
|
+
expect(mockGetRegistryVersion).toHaveBeenCalledWith('nonexistent-package');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('getUpgradeType edge cases', () => {
|
|
135
|
+
it('handles zero versions', () => {
|
|
136
|
+
expect((0, check_1.getUpgradeType)('0.1.0', '1.0.0')).toBe('MAJOR');
|
|
137
|
+
expect((0, check_1.getUpgradeType)('0.0.1', '0.1.0')).toBe('MINOR');
|
|
138
|
+
expect((0, check_1.getUpgradeType)('0.0.1', '0.0.2')).toBe('PATCH');
|
|
139
|
+
});
|
|
140
|
+
it('handles prerelease versions', () => {
|
|
141
|
+
// Prerelease tags are stripped, major/minor detection still works
|
|
142
|
+
expect((0, check_1.getUpgradeType)('1.0.0-alpha', '2.0.0')).toBe('MAJOR');
|
|
143
|
+
expect((0, check_1.getUpgradeType)('1.0.0-beta', '1.1.0')).toBe('MINOR');
|
|
144
|
+
});
|
|
145
|
+
it('handles versions with v prefix', () => {
|
|
146
|
+
expect((0, check_1.getUpgradeType)('v1.0.0', '2.0.0')).toBe('MAJOR');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -144,6 +144,41 @@ async function checkCommand(options = {}) {
|
|
|
144
144
|
else if ((0, npm_1.isUsingCloudApi)()) {
|
|
145
145
|
console.log(chalk_1.default.gray('\n ☁️ Connected to Linchpin cloud.'));
|
|
146
146
|
}
|
|
147
|
+
// Sync scan results to cloud if logged in
|
|
148
|
+
if ((0, npm_1.isUsingCloudApi)()) {
|
|
149
|
+
const scanResults = packageData.map(({ name, current, latest }) => {
|
|
150
|
+
const upgradeType = getUpgradeType(current, latest);
|
|
151
|
+
const riskLevel = upgradeType === 'MAJOR' ? 'critical' : upgradeType === 'MINOR' ? 'warning' : 'safe';
|
|
152
|
+
const risk = riskMap.get(name);
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
current,
|
|
156
|
+
latest,
|
|
157
|
+
riskLevel,
|
|
158
|
+
reason: risk?.risk || '',
|
|
159
|
+
aiRisk: risk?.risk,
|
|
160
|
+
safeVersion: risk?.safeVersion,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
const total = packageData.length;
|
|
164
|
+
const safeCount = total - majorCount - minorCount - patchCount;
|
|
165
|
+
const healthScore = total > 0
|
|
166
|
+
? Math.round(((safeCount * 100) + (patchCount * 80) + (minorCount * 50) + (majorCount * 0)) / total)
|
|
167
|
+
: 100;
|
|
168
|
+
// Fire and forget - don't block CLI output
|
|
169
|
+
(0, npm_1.apiRequest)('/api/cli/scan', {
|
|
170
|
+
healthScore,
|
|
171
|
+
packagesCount: total,
|
|
172
|
+
criticalCount: majorCount,
|
|
173
|
+
warningCount: minorCount,
|
|
174
|
+
scanResults,
|
|
175
|
+
deepAnalysis: isDeep,
|
|
176
|
+
}).then(result => {
|
|
177
|
+
if (result.error && process.env.DEBUG) {
|
|
178
|
+
console.error('Failed to sync scan:', result.error);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
147
182
|
// Action hints
|
|
148
183
|
console.log(chalk_1.default.gray('\n 💡 Actions:'));
|
|
149
184
|
if (!isDeep && useAI) {
|
|
@@ -41,6 +41,7 @@ exports.logoutCommand = logoutCommand;
|
|
|
41
41
|
exports.whoamiCommand = whoamiCommand;
|
|
42
42
|
const chalk_1 = __importDefault(require("chalk"));
|
|
43
43
|
const auth_1 = require("../lib/auth");
|
|
44
|
+
const sentry_1 = require("../lib/sentry");
|
|
44
45
|
const API_BASE = process.env.LINCHPIN_API_URL || 'https://getlinchpin.dev';
|
|
45
46
|
// Open URL in browser (cross-platform)
|
|
46
47
|
function openBrowser(url) {
|
|
@@ -144,6 +145,9 @@ async function loginCommand() {
|
|
|
144
145
|
// Step 4: Save credentials
|
|
145
146
|
try {
|
|
146
147
|
(0, auth_1.saveCredentials)(token);
|
|
148
|
+
// Set user in Sentry for error tracking (using token hash for anonymity)
|
|
149
|
+
const userHash = token.substring(0, 16);
|
|
150
|
+
(0, sentry_1.setUser)({ id: userHash });
|
|
147
151
|
console.log(chalk_1.default.green.bold(' ✓ Successfully logged in!\n'));
|
|
148
152
|
console.log(chalk_1.default.gray(` Credentials saved to: ${(0, auth_1.getCredentialsPath)()}`));
|
|
149
153
|
console.log(chalk_1.default.gray(' You can now use AI-powered features.\n'));
|
|
@@ -157,6 +161,8 @@ async function logoutCommand() {
|
|
|
157
161
|
const { clearCredentials } = await Promise.resolve().then(() => __importStar(require('../lib/auth')));
|
|
158
162
|
const hadCredentials = (0, auth_1.loadCredentials)() !== null;
|
|
159
163
|
clearCredentials();
|
|
164
|
+
// Clear user from Sentry
|
|
165
|
+
(0, sentry_1.clearUser)();
|
|
160
166
|
if (hadCredentials) {
|
|
161
167
|
console.log(chalk_1.default.green('\n ✓ Successfully logged out.\n'));
|
|
162
168
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.planCommand = planCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const readline_1 = __importDefault(require("readline"));
|
|
11
|
+
const logger_1 = require("../utils/logger");
|
|
12
|
+
// Console helpers
|
|
13
|
+
const log = (msg) => console.log(msg);
|
|
14
|
+
const highlight = (msg) => console.log(`\x1b[36m${msg}\x1b[0m`);
|
|
15
|
+
const { info, success, error } = logger_1.logger;
|
|
16
|
+
const warning = logger_1.logger.warn;
|
|
17
|
+
function askQuestion(query) {
|
|
18
|
+
const rl = readline_1.default.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stdout,
|
|
21
|
+
});
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
rl.question(query, (answer) => {
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve(answer.trim().toLowerCase());
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function planCommand(filePath, options) {
|
|
30
|
+
// Resolve file path
|
|
31
|
+
const resolvedPath = path_1.default.resolve(process.cwd(), filePath);
|
|
32
|
+
// Check if file exists
|
|
33
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
34
|
+
error(`Plan file not found: ${resolvedPath}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
// Read and parse plan
|
|
38
|
+
let plan;
|
|
39
|
+
try {
|
|
40
|
+
const content = fs_1.default.readFileSync(resolvedPath, 'utf-8');
|
|
41
|
+
plan = JSON.parse(content);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
error(`Failed to parse plan file: ${err}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
// Validate plan structure
|
|
48
|
+
if (!plan.steps || !Array.isArray(plan.steps) || plan.steps.length === 0) {
|
|
49
|
+
error('Invalid plan file: no steps found');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
log('');
|
|
53
|
+
highlight('='.repeat(50));
|
|
54
|
+
highlight(' LINCHPIN UPGRADE PLAN');
|
|
55
|
+
highlight('='.repeat(50));
|
|
56
|
+
log('');
|
|
57
|
+
if (plan.created) {
|
|
58
|
+
info(`Plan created: ${new Date(plan.created).toLocaleString()}`);
|
|
59
|
+
}
|
|
60
|
+
info(`Total steps: ${plan.steps.length}`);
|
|
61
|
+
log('');
|
|
62
|
+
// Show warnings
|
|
63
|
+
if (plan.warnings && plan.warnings.length > 0) {
|
|
64
|
+
warning('Warnings:');
|
|
65
|
+
plan.warnings.forEach((w) => {
|
|
66
|
+
log(` - ${w}`);
|
|
67
|
+
});
|
|
68
|
+
log('');
|
|
69
|
+
}
|
|
70
|
+
// Show steps
|
|
71
|
+
log('Steps:');
|
|
72
|
+
plan.steps.forEach((step) => {
|
|
73
|
+
log(` ${step.order}. ${step.group}`);
|
|
74
|
+
log(` Packages: ${step.packages.join(', ')}`);
|
|
75
|
+
log(` Note: ${step.note}`);
|
|
76
|
+
log(` Command: ${step.command}`);
|
|
77
|
+
log('');
|
|
78
|
+
});
|
|
79
|
+
if (options.dryRun) {
|
|
80
|
+
info('Dry run mode - no changes will be made');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Confirm unless --yes flag
|
|
84
|
+
if (!options.yes) {
|
|
85
|
+
const answer = await askQuestion('Execute this plan? (y/n): ');
|
|
86
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
87
|
+
info('Plan execution cancelled');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
log('');
|
|
92
|
+
highlight('Executing plan...');
|
|
93
|
+
log('');
|
|
94
|
+
// Create backup of package.json and package-lock.json
|
|
95
|
+
const backupDir = path_1.default.join(process.cwd(), '.linchpin-backup');
|
|
96
|
+
if (!fs_1.default.existsSync(backupDir)) {
|
|
97
|
+
fs_1.default.mkdirSync(backupDir);
|
|
98
|
+
}
|
|
99
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
100
|
+
const pkgJsonPath = path_1.default.join(process.cwd(), 'package.json');
|
|
101
|
+
const lockPath = path_1.default.join(process.cwd(), 'package-lock.json');
|
|
102
|
+
if (fs_1.default.existsSync(pkgJsonPath)) {
|
|
103
|
+
fs_1.default.copyFileSync(pkgJsonPath, path_1.default.join(backupDir, `package.json.${timestamp}`));
|
|
104
|
+
}
|
|
105
|
+
if (fs_1.default.existsSync(lockPath)) {
|
|
106
|
+
fs_1.default.copyFileSync(lockPath, path_1.default.join(backupDir, `package-lock.json.${timestamp}`));
|
|
107
|
+
}
|
|
108
|
+
success('Backup created in .linchpin-backup/');
|
|
109
|
+
log('');
|
|
110
|
+
// Execute each step
|
|
111
|
+
let completed = 0;
|
|
112
|
+
let failed = 0;
|
|
113
|
+
for (const step of plan.steps) {
|
|
114
|
+
log(`Step ${step.order}: ${step.group}`);
|
|
115
|
+
info(`Running: ${step.command}`);
|
|
116
|
+
try {
|
|
117
|
+
(0, child_process_1.execSync)(step.command, {
|
|
118
|
+
cwd: process.cwd(),
|
|
119
|
+
stdio: 'inherit',
|
|
120
|
+
});
|
|
121
|
+
success(`Step ${step.order} completed`);
|
|
122
|
+
completed++;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
error(`Step ${step.order} failed: ${err}`);
|
|
126
|
+
failed++;
|
|
127
|
+
// Ask if user wants to continue
|
|
128
|
+
if (!options.yes) {
|
|
129
|
+
const answer = await askQuestion('Continue with remaining steps? (y/n): ');
|
|
130
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
131
|
+
info('Plan execution stopped');
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
log('');
|
|
137
|
+
}
|
|
138
|
+
// Summary
|
|
139
|
+
log('');
|
|
140
|
+
highlight('='.repeat(50));
|
|
141
|
+
highlight(' PLAN EXECUTION COMPLETE');
|
|
142
|
+
highlight('='.repeat(50));
|
|
143
|
+
log('');
|
|
144
|
+
success(`Completed: ${completed}/${plan.steps.length} steps`);
|
|
145
|
+
if (failed > 0) {
|
|
146
|
+
warning(`Failed: ${failed} step(s)`);
|
|
147
|
+
}
|
|
148
|
+
log('');
|
|
149
|
+
info('Backup files are in .linchpin-backup/');
|
|
150
|
+
info('Run "npm test" to verify your app still works!');
|
|
151
|
+
log('');
|
|
152
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.initSentry = initSentry;
|
|
37
|
+
exports.captureException = captureException;
|
|
38
|
+
exports.captureMessage = captureMessage;
|
|
39
|
+
exports.setUser = setUser;
|
|
40
|
+
exports.clearUser = clearUser;
|
|
41
|
+
exports.flush = flush;
|
|
42
|
+
const Sentry = __importStar(require("@sentry/node"));
|
|
43
|
+
const SENTRY_DSN = process.env.SENTRY_DSN || 'https://9cf669968b5daf7a525d9e8a4dd66ee7@o4510643901956096.ingest.us.sentry.io/4510687506333696';
|
|
44
|
+
// Version is passed in to avoid path issues after compilation
|
|
45
|
+
let isInitialized = false;
|
|
46
|
+
let cliVersion = '0.0.0';
|
|
47
|
+
function initSentry(version) {
|
|
48
|
+
// Skip if already initialized
|
|
49
|
+
if (isInitialized) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (version) {
|
|
53
|
+
cliVersion = version;
|
|
54
|
+
}
|
|
55
|
+
Sentry.init({
|
|
56
|
+
dsn: SENTRY_DSN,
|
|
57
|
+
environment: process.env.NODE_ENV || 'production',
|
|
58
|
+
release: `linchpin-cli@${cliVersion}`,
|
|
59
|
+
// Only send errors, not transactions (keeps it lightweight)
|
|
60
|
+
tracesSampleRate: 0,
|
|
61
|
+
// Filter out sensitive data
|
|
62
|
+
beforeSend(event) {
|
|
63
|
+
// Remove any file paths that might contain user info
|
|
64
|
+
if (event.exception?.values) {
|
|
65
|
+
event.exception.values.forEach((exception) => {
|
|
66
|
+
if (exception.stacktrace?.frames) {
|
|
67
|
+
exception.stacktrace.frames.forEach((frame) => {
|
|
68
|
+
// Normalize paths to hide user directories
|
|
69
|
+
if (frame.filename) {
|
|
70
|
+
frame.filename = frame.filename.replace(/^.*[\\\/]node_modules/, 'node_modules');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return event;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
isInitialized = true;
|
|
80
|
+
}
|
|
81
|
+
function captureException(error, context) {
|
|
82
|
+
if (!isInitialized) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
Sentry.withScope((scope) => {
|
|
86
|
+
if (context) {
|
|
87
|
+
scope.setExtras(context);
|
|
88
|
+
}
|
|
89
|
+
Sentry.captureException(error);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function captureMessage(message, level = 'info') {
|
|
93
|
+
if (!isInitialized) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
Sentry.captureMessage(message, level);
|
|
97
|
+
}
|
|
98
|
+
function setUser(user) {
|
|
99
|
+
if (!isInitialized) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
Sentry.setUser(user);
|
|
103
|
+
}
|
|
104
|
+
function clearUser() {
|
|
105
|
+
if (!isInitialized) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
Sentry.setUser(null);
|
|
109
|
+
}
|
|
110
|
+
// Flush events before process exits
|
|
111
|
+
async function flush(timeout = 2000) {
|
|
112
|
+
if (!isInitialized) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return Sentry.flush(timeout);
|
|
116
|
+
}
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.logger = void 0;
|
|
4
|
+
const sentry_1 = require("../lib/sentry");
|
|
4
5
|
// ANSI color codes for terminal output
|
|
5
6
|
const colors = {
|
|
6
7
|
reset: '\x1b[0m',
|
|
@@ -21,9 +22,16 @@ exports.logger = {
|
|
|
21
22
|
},
|
|
22
23
|
warn: (message) => {
|
|
23
24
|
console.log(`${colors.yellow}[WARN]${colors.reset} ${message}`);
|
|
25
|
+
(0, sentry_1.captureMessage)(message, 'warning');
|
|
24
26
|
},
|
|
25
|
-
error: (message) => {
|
|
27
|
+
error: (message, error) => {
|
|
26
28
|
console.log(`${colors.red}[ERROR]${colors.reset} ${message}`);
|
|
29
|
+
if (error) {
|
|
30
|
+
(0, sentry_1.captureException)(error, { message });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
(0, sentry_1.captureMessage)(message, 'error');
|
|
34
|
+
}
|
|
27
35
|
},
|
|
28
36
|
debug: (message) => {
|
|
29
37
|
if (process.env.DEBUG) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linchpin-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Linchpin: The 'Don't Break My App' Tool - AI-powered dependency management for solo founders",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"node": ">=16.0.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@sentry/node": "^10.32.1",
|
|
42
43
|
"chalk": "^4.1.2",
|
|
43
44
|
"cli-table3": "^0.6.5",
|
|
44
45
|
"commander": "^11.1.0",
|