linchpin-cli 0.2.1 → 0.2.3

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 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('0.1.0');
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,155 @@
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
+ detectProjectContext: jest.fn().mockReturnValue({
8
+ isExpo: false,
9
+ expoVersion: undefined,
10
+ isMonorepo: false,
11
+ monorepoTool: undefined,
12
+ enginesNode: undefined,
13
+ }),
14
+ }));
15
+ jest.mock('../lib/npm', () => ({
16
+ getRegistryVersion: jest.fn(),
17
+ hasApiKey: jest.fn(),
18
+ isUsingCloudApi: jest.fn(),
19
+ apiRequest: jest.fn(),
20
+ }));
21
+ jest.mock('../lib/ai', () => ({
22
+ getLatestVersion: jest.fn(),
23
+ getBatchRiskAnalysis: 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 mockGetPackageJson = files_1.getPackageJson;
30
+ const mockGetRegistryVersion = npm_1.getRegistryVersion;
31
+ const mockHasApiKey = npm_1.hasApiKey;
32
+ const mockIsUsingCloudApi = npm_1.isUsingCloudApi;
33
+ describe('checkCommand integration', () => {
34
+ let consoleSpy;
35
+ let stdoutSpy;
36
+ beforeEach(() => {
37
+ jest.clearAllMocks();
38
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
39
+ stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation();
40
+ // Default mocks
41
+ mockHasApiKey.mockReturnValue(false);
42
+ mockIsUsingCloudApi.mockReturnValue(false);
43
+ });
44
+ afterEach(() => {
45
+ consoleSpy.mockRestore();
46
+ stdoutSpy.mockRestore();
47
+ });
48
+ describe('when package.json is missing', () => {
49
+ it('shows error message', async () => {
50
+ mockGetPackageJson.mockResolvedValue(null);
51
+ await (0, check_1.checkCommand)({});
52
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No package.json found'));
53
+ });
54
+ });
55
+ describe('when package.json has no dependencies', () => {
56
+ it('shows warning message', async () => {
57
+ mockGetPackageJson.mockResolvedValue({
58
+ name: 'test-project',
59
+ version: '1.0.0',
60
+ dependencies: {},
61
+ devDependencies: {},
62
+ });
63
+ await (0, check_1.checkCommand)({});
64
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('no dependencies listed'));
65
+ });
66
+ });
67
+ describe('when scanning dependencies (free mode)', () => {
68
+ it('checks all dependencies and displays results', async () => {
69
+ mockGetPackageJson.mockResolvedValue({
70
+ name: 'test-project',
71
+ version: '1.0.0',
72
+ dependencies: {
73
+ 'express': '^4.18.0',
74
+ 'lodash': '^4.17.21',
75
+ },
76
+ devDependencies: {
77
+ 'jest': '^29.0.0',
78
+ },
79
+ });
80
+ mockGetRegistryVersion
81
+ .mockReturnValueOnce('4.21.0') // express - MINOR
82
+ .mockReturnValueOnce('4.17.21') // lodash - OK
83
+ .mockReturnValueOnce('30.0.0'); // jest - MAJOR
84
+ await (0, check_1.checkCommand)({});
85
+ // Should check each package
86
+ expect(mockGetRegistryVersion).toHaveBeenCalledTimes(3);
87
+ expect(mockGetRegistryVersion).toHaveBeenCalledWith('express');
88
+ expect(mockGetRegistryVersion).toHaveBeenCalledWith('lodash');
89
+ expect(mockGetRegistryVersion).toHaveBeenCalledWith('jest');
90
+ // Should display scanning message
91
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Checking 3 packages'));
92
+ // Should show free mode hint
93
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Free mode'));
94
+ });
95
+ it('counts upgrade types correctly', async () => {
96
+ mockGetPackageJson.mockResolvedValue({
97
+ name: 'test-project',
98
+ version: '1.0.0',
99
+ dependencies: {
100
+ 'pkg-major': '1.0.0',
101
+ 'pkg-minor': '1.0.0',
102
+ 'pkg-patch': '1.0.0',
103
+ 'pkg-ok': '1.0.0',
104
+ },
105
+ });
106
+ mockGetRegistryVersion
107
+ .mockReturnValueOnce('2.0.0') // MAJOR
108
+ .mockReturnValueOnce('1.1.0') // MINOR
109
+ .mockReturnValueOnce('1.0.1') // PATCH
110
+ .mockReturnValueOnce('1.0.0'); // OK
111
+ await (0, check_1.checkCommand)({});
112
+ // Should display summary with correct counts
113
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 major'));
114
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 minor'));
115
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 patch'));
116
+ });
117
+ });
118
+ describe('deep scan mode', () => {
119
+ it('requires API key for deep scan', async () => {
120
+ mockHasApiKey.mockReturnValue(false);
121
+ await (0, check_1.checkCommand)({ deep: true });
122
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deep scan requires an API key'));
123
+ });
124
+ });
125
+ describe('error handling', () => {
126
+ it('handles npm registry errors gracefully', async () => {
127
+ mockGetPackageJson.mockResolvedValue({
128
+ name: 'test-project',
129
+ version: '1.0.0',
130
+ dependencies: {
131
+ 'nonexistent-package': '1.0.0',
132
+ },
133
+ });
134
+ mockGetRegistryVersion.mockReturnValue('Unknown');
135
+ await (0, check_1.checkCommand)({});
136
+ // Should not throw, should display result
137
+ expect(mockGetRegistryVersion).toHaveBeenCalledWith('nonexistent-package');
138
+ });
139
+ });
140
+ });
141
+ describe('getUpgradeType edge cases', () => {
142
+ it('handles zero versions', () => {
143
+ expect((0, check_1.getUpgradeType)('0.1.0', '1.0.0')).toBe('MAJOR');
144
+ expect((0, check_1.getUpgradeType)('0.0.1', '0.1.0')).toBe('MINOR');
145
+ expect((0, check_1.getUpgradeType)('0.0.1', '0.0.2')).toBe('PATCH');
146
+ });
147
+ it('handles prerelease versions', () => {
148
+ // Prerelease tags are stripped, major/minor detection still works
149
+ expect((0, check_1.getUpgradeType)('1.0.0-alpha', '2.0.0')).toBe('MAJOR');
150
+ expect((0, check_1.getUpgradeType)('1.0.0-beta', '1.1.0')).toBe('MINOR');
151
+ });
152
+ it('handles versions with v prefix', () => {
153
+ expect((0, check_1.getUpgradeType)('v1.0.0', '2.0.0')).toBe('MAJOR');
154
+ });
155
+ });
@@ -29,3 +29,67 @@ describe('getPackageJson', () => {
29
29
  expect(typeof pkg?.devDependencies).toBe('object');
30
30
  });
31
31
  });
32
+ describe('detectProjectContext', () => {
33
+ it('detects Expo projects', () => {
34
+ const pkg = {
35
+ name: 'my-expo-app',
36
+ version: '1.0.0',
37
+ dependencies: {
38
+ 'expo': '^52.0.0',
39
+ 'react': '^18.2.0',
40
+ 'react-native': '0.76.0',
41
+ },
42
+ };
43
+ const context = (0, files_1.detectProjectContext)(pkg);
44
+ expect(context.isExpo).toBe(true);
45
+ expect(context.expoVersion).toBe('52.0.0');
46
+ });
47
+ it('detects non-Expo projects', () => {
48
+ const pkg = {
49
+ name: 'my-web-app',
50
+ version: '1.0.0',
51
+ dependencies: {
52
+ 'react': '^18.2.0',
53
+ 'next': '^14.0.0',
54
+ },
55
+ };
56
+ const context = (0, files_1.detectProjectContext)(pkg);
57
+ expect(context.isExpo).toBe(false);
58
+ expect(context.expoVersion).toBeUndefined();
59
+ });
60
+ it('detects Turborepo monorepo', () => {
61
+ const pkg = {
62
+ name: 'my-monorepo',
63
+ version: '1.0.0',
64
+ devDependencies: {
65
+ 'turbo': '^2.0.0',
66
+ },
67
+ };
68
+ const context = (0, files_1.detectProjectContext)(pkg);
69
+ expect(context.isMonorepo).toBe(true);
70
+ expect(context.monorepoTool).toBe('turborepo');
71
+ });
72
+ it('detects Nx monorepo', () => {
73
+ const pkg = {
74
+ name: 'my-nx-workspace',
75
+ version: '1.0.0',
76
+ devDependencies: {
77
+ 'nx': '^18.0.0',
78
+ },
79
+ };
80
+ const context = (0, files_1.detectProjectContext)(pkg);
81
+ expect(context.isMonorepo).toBe(true);
82
+ expect(context.monorepoTool).toBe('nx');
83
+ });
84
+ it('detects Node.js engine requirement', () => {
85
+ const pkg = {
86
+ name: 'my-app',
87
+ version: '1.0.0',
88
+ engines: {
89
+ node: '>=20.0.0',
90
+ },
91
+ };
92
+ const context = (0, files_1.detectProjectContext)(pkg);
93
+ expect(context.enginesNode).toBe('>=20.0.0');
94
+ });
95
+ });
@@ -59,6 +59,17 @@ async function checkCommand(options = {}) {
59
59
  console.log(chalk_1.default.yellow('⚠️ Found package.json, but no dependencies listed.'));
60
60
  return;
61
61
  }
62
+ // Detect project context
63
+ const projectContext = (0, files_1.detectProjectContext)(pkg);
64
+ // Show Expo detection
65
+ if (projectContext.isExpo) {
66
+ console.log(chalk_1.default.magenta.bold(`📱 Expo project detected`) + chalk_1.default.magenta(` (SDK ${projectContext.expoVersion || 'unknown'})`));
67
+ console.log(chalk_1.default.gray(' Expo-specific compatibility rules will be applied.\n'));
68
+ }
69
+ // Show monorepo detection
70
+ if (projectContext.isMonorepo) {
71
+ console.log(chalk_1.default.cyan(`📦 Monorepo detected (${projectContext.monorepoTool || 'workspaces'})\n`));
72
+ }
62
73
  console.log(chalk_1.default.yellow(`⚡ Checking ${depEntries.length} packages via ${source}...\n`));
63
74
  // Collect version info
64
75
  const packageData = [];
@@ -81,7 +92,7 @@ async function checkCommand(options = {}) {
81
92
  if (isDeep && useAI) {
82
93
  const modeLabel = isTechnical ? 'technical' : 'plain English';
83
94
  console.log(chalk_1.default.yellow(`\n🧠 Analyzing upgrade risks (${modeLabel} mode)...\n`));
84
- const risks = await (0, ai_1.getBatchRiskAnalysis)(packageData, isTechnical);
95
+ const risks = await (0, ai_1.getBatchRiskAnalysis)(packageData, isTechnical, projectContext);
85
96
  risks.forEach(r => riskMap.set(r.name, r));
86
97
  }
87
98
  // Build table
@@ -137,6 +148,16 @@ async function checkCommand(options = {}) {
137
148
  chalk_1.default.yellow(`${minorCount} minor`) + ` · ` +
138
149
  chalk_1.default.green(`${patchCount} patch`));
139
150
  }
151
+ // Ambient marketing - show when value delivered (critical issues found), ~30% of runs
152
+ if (majorCount > 0 && Math.random() < 0.3) {
153
+ const messages = [
154
+ `🛡️ Caught ${majorCount} breaking change${majorCount > 1 ? 's' : ''} before production. Share: linchpin.dev`,
155
+ `💡 Found this useful? Tell a friend: npx linchpin-cli`,
156
+ `🚀 Avoiding broken deploys? Share Linchpin: linchpin.dev`,
157
+ ];
158
+ const msg = messages[Math.floor(Math.random() * messages.length)];
159
+ console.log(chalk_1.default.cyan(`\n ${msg}`));
160
+ }
140
161
  // Mode indicator
141
162
  if (!useAI) {
142
163
  console.log(chalk_1.default.gray('\n ℹ️ Free mode (npm registry). Run `linchpin login` for AI features.'));
@@ -173,6 +194,14 @@ async function checkCommand(options = {}) {
173
194
  warningCount: minorCount,
174
195
  scanResults,
175
196
  deepAnalysis: isDeep,
197
+ // Include project context for Expo-aware analysis
198
+ projectContext: {
199
+ isExpo: projectContext.isExpo,
200
+ expoVersion: projectContext.expoVersion,
201
+ isMonorepo: projectContext.isMonorepo,
202
+ monorepoTool: projectContext.monorepoTool,
203
+ enginesNode: projectContext.enginesNode,
204
+ },
176
205
  }).then(result => {
177
206
  if (result.error && process.env.DEBUG) {
178
207
  console.error('Failed to sync scan:', result.error);
@@ -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
+ }
@@ -99,7 +99,7 @@ Will this break my app? Is it worth the headache? Give me the honest truth in pl
99
99
  return 'Could not fetch risks.';
100
100
  }
101
101
  }
102
- async function getBatchRiskAnalysis(packages, technical = false) {
102
+ async function getBatchRiskAnalysis(packages, technical = false, projectContext) {
103
103
  const outdatedPkgs = packages.filter(p => {
104
104
  const cleanCurrent = p.current.replace(/^[\^~]/, '');
105
105
  return cleanCurrent !== p.latest && p.latest !== 'Error' && p.latest !== 'Unknown';
@@ -113,6 +113,7 @@ async function getBatchRiskAnalysis(packages, technical = false) {
113
113
  action: 'batch_risk',
114
114
  packages: outdatedPkgs,
115
115
  technical,
116
+ projectContext, // Include Expo/monorepo context
116
117
  });
117
118
  if (result.error) {
118
119
  console.error('Cloud API error:', result.error);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getPackageJson = getPackageJson;
4
+ exports.detectProjectContext = detectProjectContext;
4
5
  const promises_1 = require("fs/promises");
5
6
  const path_1 = require("path");
6
7
  async function getPackageJson(dir = process.cwd()) {
@@ -14,3 +15,28 @@ async function getPackageJson(dir = process.cwd()) {
14
15
  return null;
15
16
  }
16
17
  }
18
+ /**
19
+ * Detect project context (Expo, monorepo, Node version)
20
+ */
21
+ function detectProjectContext(pkg) {
22
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
23
+ // Detect Expo
24
+ const expoVersion = allDeps['expo'];
25
+ const isExpo = !!expoVersion;
26
+ // Detect monorepo (basic detection)
27
+ const hasWorkspaces = !!pkg.workspaces;
28
+ const hasTurbo = !!allDeps['turbo'];
29
+ const hasNx = !!allDeps['nx'] || !!allDeps['@nx/workspace'];
30
+ const hasLerna = !!allDeps['lerna'];
31
+ const isMonorepo = hasWorkspaces || hasTurbo || hasNx || hasLerna;
32
+ const monorepoTool = hasTurbo ? 'turborepo' : hasNx ? 'nx' : hasLerna ? 'lerna' : hasWorkspaces ? 'workspaces' : undefined;
33
+ // Get Node.js engine requirement
34
+ const enginesNode = pkg.engines?.node;
35
+ return {
36
+ isExpo,
37
+ expoVersion: expoVersion?.replace(/^[\^~]/, ''),
38
+ isMonorepo,
39
+ monorepoTool,
40
+ enginesNode,
41
+ };
42
+ }
@@ -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
+ }
@@ -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.1",
3
+ "version": "0.2.3",
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",
@@ -49,12 +50,20 @@
49
50
  "resend": "^6.7.0"
50
51
  },
51
52
  "devDependencies": {
53
+ "@babel/core": "^7.28.5",
52
54
  "@types/inquirer": "^9.0.9",
53
55
  "@types/jest": "^30.0.0",
54
- "@types/node": "^20.10.0",
56
+ "@types/node": "^25.0.6",
57
+ "@types/react": "^19.2.8",
58
+ "@types/react-dom": "^19.2.3",
59
+ "@typescript-eslint/eslint-plugin": "^8.52.0",
60
+ "@typescript-eslint/parser": "^8.52.0",
61
+ "eslint": "^9.39.2",
62
+ "eslint-config-expo": "^10.0.0",
63
+ "eslint-config-prettier": "^10.1.8",
55
64
  "jest": "^30.2.0",
56
65
  "ts-jest": "^29.4.6",
57
66
  "ts-node": "^10.9.1",
58
- "typescript": "^5.3.2"
67
+ "typescript": "^5.9.3"
59
68
  }
60
69
  }