forged-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/forged.js CHANGED
@@ -1,8 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const [,, command] = process.argv;
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const { version } = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
9
+
10
+ const [,, command, ...args] = process.argv;
4
11
 
5
12
  const commands = {
13
+ readme: 'Generate a README.md for the current project',
14
+ scan: 'Audit dependencies for typosquats, bad scripts, and suspicious binaries',
6
15
  init: 'Scaffold a new dev environment',
7
16
  new: 'Create a new project with GitHub setup',
8
17
  install: 'Install Forged into an existing shell config',
@@ -23,11 +32,22 @@ ${Object.entries(commands).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`)
23
32
  }
24
33
 
25
34
  if (command === 'version') {
26
- const { createRequire } = await import('module');
27
- const require = createRequire(import.meta.url);
28
- const { version } = require('../package.json');
29
35
  console.log(`forged-cli v${version}`);
30
36
  process.exit(0);
31
37
  }
32
38
 
39
+ if (command === 'readme') {
40
+ const { readmeCommand } = await import('../src/commands/readme.js');
41
+ await readmeCommand(args[0] || 'README.md');
42
+ process.exit(0);
43
+ }
44
+
45
+ if (command === 'scan') {
46
+ const { scanCommand } = await import('../src/commands/scan.js');
47
+ const report = args.includes('--report') || args.includes('--report-md');
48
+ const reportFormat = args.includes('--report-md') ? 'markdown' : 'json';
49
+ await scanCommand(process.cwd(), { report, reportFormat });
50
+ process.exit(0);
51
+ }
52
+
33
53
  console.log(`⚒ Forged — '${command}' coming soon.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forged-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A complete dev environment toolkit — shell, workflows, and project scaffolding in one install.",
5
5
  "keywords": [
6
6
  "cli",
@@ -29,5 +29,8 @@
29
29
  },
30
30
  "scripts": {
31
31
  "start": "node bin/forged.js"
32
+ },
33
+ "dependencies": {
34
+ "inquirer": "^13.4.2"
32
35
  }
33
36
  }
@@ -0,0 +1,84 @@
1
+ import { createRequire } from 'module';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import { resolve } from 'path';
4
+ import { detectProjectInfo } from '../utils/detectProject.js';
5
+ import { generateMarkdown } from '../utils/generateMarkdown.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const { prompt } = require('inquirer');
9
+
10
+ const green = '\x1b[32m';
11
+ const purple = '\x1b[35m';
12
+ const reset = '\x1b[0m';
13
+
14
+ export async function readmeCommand(outputPath = 'README.md') {
15
+ const detected = detectProjectInfo();
16
+
17
+ console.log(`\n${green}⚒ Forged README Generator${reset}`);
18
+ if (detected.github) console.log(` Detected GitHub user: ${detected.github}`);
19
+ console.log(' Press Enter to accept defaults.\n');
20
+
21
+ const answers = await prompt([
22
+ {
23
+ type: 'input',
24
+ name: 'title',
25
+ message: `${green}Project name:${reset}`,
26
+ default: detected.title,
27
+ },
28
+ {
29
+ type: 'input',
30
+ name: 'description',
31
+ message: `${green}Short description:${reset}`,
32
+ },
33
+ {
34
+ type: 'input',
35
+ name: 'toDo',
36
+ message: `${green}To-do items (leave blank to skip section):${reset}`,
37
+ },
38
+ {
39
+ type: 'list',
40
+ name: 'license',
41
+ message: `${green}License:${reset}`,
42
+ choices: ['MIT', 'APACHE 2.0', 'GPL 3.0', 'BSD 3', 'None'],
43
+ },
44
+ {
45
+ type: 'input',
46
+ name: 'installation',
47
+ message: `${green}Install command:${reset}`,
48
+ default: detected.installCommand,
49
+ },
50
+ {
51
+ type: 'input',
52
+ name: 'test',
53
+ message: `${green}Test command:${reset}`,
54
+ default: detected.testCommand,
55
+ },
56
+ {
57
+ type: 'input',
58
+ name: 'usage',
59
+ message: `${green}Usage instructions:${reset}`,
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'contributing',
64
+ message: `${green}Contributing guidelines:${reset}`,
65
+ default: 'Fork the project and open a pull request with your changes',
66
+ },
67
+ {
68
+ type: 'input',
69
+ name: 'github',
70
+ message: `${green}GitHub username:${reset}`,
71
+ default: detected.github,
72
+ },
73
+ {
74
+ type: 'input',
75
+ name: 'email',
76
+ message: `${green}Email address:${reset}`,
77
+ default: detected.email,
78
+ },
79
+ ]);
80
+
81
+ const markdown = generateMarkdown(answers);
82
+ await writeFile(resolve(outputPath), markdown, 'utf8');
83
+ console.log(`\n${purple}✔ README written to ${outputPath}${reset}\n`);
84
+ }
@@ -0,0 +1,231 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'fs';
2
+ import { createHash } from 'crypto';
3
+ import { join, basename } from 'path';
4
+ import { isSuspiciousName } from '../utils/levenshtein.js';
5
+ import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
6
+ import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
7
+
8
+ const green = '\x1b[32m';
9
+ const yellow = '\x1b[33m';
10
+ const red = '\x1b[31m';
11
+ const bold = '\x1b[1m';
12
+ const reset = '\x1b[0m';
13
+
14
+ function checkPackageJson(cwd) {
15
+ const pkgPath = join(cwd, 'package.json');
16
+ if (!existsSync(pkgPath)) return { deps: {}, error: 'No package.json found' };
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
19
+ return {
20
+ deps: { ...pkg.dependencies, ...pkg.devDependencies },
21
+ scripts: pkg.scripts || {},
22
+ name: pkg.name,
23
+ version: pkg.version,
24
+ };
25
+ } catch {
26
+ return { deps: {}, error: 'Could not parse package.json' };
27
+ }
28
+ }
29
+
30
+ function scanBinaries(cwd) {
31
+ const binDir = join(cwd, 'node_modules', '.bin');
32
+ if (!existsSync(binDir)) return [];
33
+ const results = [];
34
+ for (const file of readdirSync(binDir)) {
35
+ const fullPath = join(binDir, file);
36
+ try {
37
+ const stat = statSync(fullPath);
38
+ if (!stat.isSymbolicLink() && stat.size > 50000) {
39
+ results.push({ file, size: stat.size, flag: 'large non-symlink binary' });
40
+ }
41
+ } catch {}
42
+ }
43
+ return results;
44
+ }
45
+
46
+ function checkDangerousScripts(scripts) {
47
+ const dangerous = [];
48
+ const patterns = [
49
+ /curl\s+.*\|.*sh/,
50
+ /wget\s+.*\|.*sh/,
51
+ /eval\s*\(/,
52
+ /base64\s+--decode/,
53
+ /python\s+-c/,
54
+ /exec\s*\(/,
55
+ ];
56
+ for (const [name, cmd] of Object.entries(scripts)) {
57
+ for (const pattern of patterns) {
58
+ if (pattern.test(cmd)) {
59
+ dangerous.push({ script: name, command: cmd });
60
+ }
61
+ }
62
+ }
63
+ return dangerous;
64
+ }
65
+
66
+ function saveJsonReport(reportPath, data) {
67
+ writeFileSync(reportPath, JSON.stringify(data, null, 2), 'utf8');
68
+ }
69
+
70
+ function saveMarkdownReport(reportPath, data) {
71
+ const { meta, findings } = data;
72
+ const lines = [
73
+ `# Forged Scan Report`,
74
+ ``,
75
+ `**Project:** ${meta.project}`,
76
+ `**Scanned:** ${meta.scannedAt}`,
77
+ `**Packages verified:** ${meta.packagesVerified}`,
78
+ `**Summary:** ${findings.errors.length} error(s), ${findings.warnings.length} warning(s)`,
79
+ ``,
80
+ ];
81
+
82
+ if (findings.errors.length > 0) {
83
+ lines.push(`## Errors`, ``);
84
+ for (const e of findings.errors) {
85
+ lines.push(`- ❌ **${e.package ? `${e.package}@${e.version}` : 'project'}**: ${e.message}`);
86
+ if (e.detail) {
87
+ lines.push(` - locked: \`${e.detail.locked}\``);
88
+ lines.push(` - registry: \`${e.detail.registry}\``);
89
+ }
90
+ }
91
+ lines.push(``);
92
+ }
93
+
94
+ if (findings.warnings.length > 0) {
95
+ lines.push(`## Warnings`, ``);
96
+ for (const w of findings.warnings) {
97
+ lines.push(`- ⚠️ **${w.package ? `${w.package}@${w.version}` : 'project'}**: ${w.message}`);
98
+ }
99
+ lines.push(``);
100
+ }
101
+
102
+ if (findings.errors.length === 0 && findings.warnings.length === 0) {
103
+ lines.push(`## Result`, ``, `✅ No issues found. All packages verified clean.`, ``);
104
+ }
105
+
106
+ writeFileSync(reportPath, lines.join('\n'), 'utf8');
107
+ }
108
+
109
+ export async function scanCommand(cwd = process.cwd(), opts = {}) {
110
+ const { report, reportFormat = 'json' } = opts;
111
+
112
+ console.log(`\n${bold}⚒ Forged Scanner${reset}`);
113
+ console.log(` Scanning: ${cwd}\n`);
114
+
115
+ const findings = { warnings: [], errors: [], info: [] };
116
+ let packagesVerified = 0;
117
+
118
+ // 1. Parse package.json
119
+ const { deps, scripts, error, name: pkgName, version: pkgVersion } = checkPackageJson(cwd);
120
+ if (error) findings.errors.push({ message: error });
121
+
122
+ // 2. Dangerous scripts
123
+ if (scripts) {
124
+ for (const d of checkDangerousScripts(scripts)) {
125
+ findings.errors.push({ message: `Dangerous script "${d.script}": ${d.command}` });
126
+ }
127
+ }
128
+
129
+ // 3. Typosquat check
130
+ for (const name of Object.keys(deps)) {
131
+ for (const { known, distance } of isSuspiciousName(name, POPULAR_PACKAGES)) {
132
+ findings.warnings.push({
133
+ package: name,
134
+ message: `${distance} character(s) from popular package "${known}" — possible typosquat`,
135
+ });
136
+ }
137
+ }
138
+
139
+ // 4. Tarball integrity verification
140
+ process.stdout.write(' Verifying tarball integrity');
141
+ const { error: integrityError, findings: integrityFindings, total } = await verifyTarballIntegrity(
142
+ cwd,
143
+ (checked, t) => {
144
+ if (checked % 10 === 0 || checked === t) {
145
+ process.stdout.write(`\r Verifying tarball integrity (${checked}/${t})...`);
146
+ }
147
+ }
148
+ );
149
+ process.stdout.write('\n');
150
+
151
+ if (integrityError) {
152
+ findings.warnings.push({ message: integrityError });
153
+ } else {
154
+ packagesVerified = total || 0;
155
+ for (const f of integrityFindings) {
156
+ (f.type === 'error' ? findings.errors : findings.warnings).push(f);
157
+ }
158
+ if (total) findings.info.push(`Verified ${total} packages against npm registry`);
159
+ }
160
+
161
+ // 5. Binary scan
162
+ for (const b of scanBinaries(cwd)) {
163
+ findings.warnings.push({
164
+ message: `node_modules/.bin/${b.file} is a large non-symlink binary (${(b.size / 1024).toFixed(1)}KB)`,
165
+ });
166
+ }
167
+
168
+ // Print results
169
+ if (findings.errors.length === 0 && findings.warnings.length === 0) {
170
+ console.log(`${green}✔ No issues found. Looks clean.${reset}\n`);
171
+ } else {
172
+ if (findings.errors.length > 0) {
173
+ console.log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
174
+ for (const e of findings.errors) {
175
+ const label = e.package ? `${e.package}@${e.version}: ` : '';
176
+ console.log(` ${red}✖${reset} ${label}${e.message}`);
177
+ if (e.detail) {
178
+ console.log(` locked: ${e.detail.locked}`);
179
+ console.log(` registry: ${e.detail.registry}`);
180
+ }
181
+ }
182
+ console.log();
183
+ }
184
+
185
+ if (findings.warnings.length > 0) {
186
+ console.log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
187
+ for (const w of findings.warnings) {
188
+ const label = w.package ? `${w.package}@${w.version}: ` : '';
189
+ console.log(` ${yellow}⚠${reset} ${label}${w.message}`);
190
+ }
191
+ console.log();
192
+ }
193
+ }
194
+
195
+ if (findings.info.length > 0) {
196
+ for (const i of findings.info) console.log(` ${green}ℹ${reset} ${i}`);
197
+ console.log();
198
+ }
199
+
200
+ console.log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s)\n`);
201
+
202
+ // Save report if requested
203
+ if (report) {
204
+ const reportData = {
205
+ meta: {
206
+ project: pkgName || basename(cwd),
207
+ version: pkgVersion || 'unknown',
208
+ scannedAt: new Date().toISOString(),
209
+ packagesVerified,
210
+ cwd,
211
+ },
212
+ findings,
213
+ summary: {
214
+ errors: findings.errors.length,
215
+ warnings: findings.warnings.length,
216
+ clean: findings.errors.length === 0 && findings.warnings.length === 0,
217
+ },
218
+ };
219
+
220
+ const ext = reportFormat === 'markdown' ? 'md' : 'json';
221
+ const reportFile = join(cwd, `forged-report.${ext}`);
222
+
223
+ if (reportFormat === 'markdown') {
224
+ saveMarkdownReport(reportFile, reportData);
225
+ } else {
226
+ saveJsonReport(reportFile, reportData);
227
+ }
228
+
229
+ console.log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
230
+ }
231
+ }
@@ -0,0 +1,48 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { basename, resolve } from 'path';
4
+
5
+ export function detectProjectInfo() {
6
+ const cwd = process.cwd();
7
+ const info = {
8
+ title: basename(cwd),
9
+ github: '',
10
+ email: '',
11
+ installCommand: 'npm i',
12
+ testCommand: 'npm test',
13
+ };
14
+
15
+ // Pull GitHub username + email from git config
16
+ try {
17
+ const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { stdio: ['pipe', 'pipe', 'ignore'] })
18
+ .toString().trim();
19
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)/);
20
+ if (match) info.github = match[1];
21
+ } catch {}
22
+
23
+ try {
24
+ info.email = execSync('git config user.email', { stdio: ['pipe', 'pipe', 'ignore'] })
25
+ .toString().trim();
26
+ } catch {}
27
+
28
+ // Detect stack and set install/test defaults
29
+ if (existsSync('package.json')) {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
32
+ if (pkg.scripts?.test && !pkg.scripts.test.includes('no test')) {
33
+ info.testCommand = 'npm test';
34
+ }
35
+ } catch {}
36
+ } else if (existsSync('requirements.txt')) {
37
+ info.installCommand = 'pip install -r requirements.txt';
38
+ info.testCommand = 'pytest';
39
+ } else if (existsSync('Cargo.toml')) {
40
+ info.installCommand = 'cargo build';
41
+ info.testCommand = 'cargo test';
42
+ } else if (existsSync('go.mod')) {
43
+ info.installCommand = 'go mod download';
44
+ info.testCommand = 'go test ./...';
45
+ }
46
+
47
+ return info;
48
+ }
@@ -0,0 +1,44 @@
1
+ import { renderLicenseBadge, renderLicenseLink, renderLicenseSection } from './licenseUtils.js';
2
+
3
+ export function generateMarkdown(data) {
4
+ return `# ${data.title}
5
+ ${renderLicenseBadge(data.license)}
6
+
7
+ ## Description
8
+ ${data.description}
9
+
10
+ ## Table of Contents
11
+
12
+ - [Installation](#installation)
13
+ - [Usage](#usage)
14
+ ${data.toDo ? '- [To Do](#to-do)\n' : ''}- [Test](#test)
15
+ - [Contributing](#contributing)
16
+ ${renderLicenseLink(data.license)}
17
+ - [Questions](#questions)
18
+ ${data.toDo ? `
19
+ ## To Do
20
+ ${data.toDo}
21
+ ` : ''}
22
+ ## Installation
23
+ \`\`\`sh
24
+ ${data.installation}
25
+ \`\`\`
26
+
27
+ ## Usage
28
+ ${data.usage}
29
+
30
+ ## Test
31
+ \`\`\`sh
32
+ ${data.test}
33
+ \`\`\`
34
+
35
+ ## Contributing
36
+ ${data.contributing}
37
+
38
+ ${renderLicenseSection(data.license)}
39
+
40
+ ## Questions
41
+ Questions? Email me at ${data.email}.
42
+ See more of my work at [github.com/${data.github}](https://github.com/${data.github}).
43
+ `;
44
+ }
@@ -0,0 +1,25 @@
1
+ // Levenshtein distance — catches typosquatted package names
2
+ export function levenshtein(a, b) {
3
+ const m = a.length, n = b.length;
4
+ const dp = Array.from({ length: m + 1 }, (_, i) =>
5
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
6
+ );
7
+ for (let i = 1; i <= m; i++)
8
+ for (let j = 1; j <= n; j++)
9
+ dp[i][j] = a[i-1] === b[j-1]
10
+ ? dp[i-1][j-1]
11
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
12
+ return dp[m][n];
13
+ }
14
+
15
+ export function isSuspiciousName(name, popularPackages, threshold = 2) {
16
+ const suspects = [];
17
+ for (const known of popularPackages) {
18
+ if (name === known) continue;
19
+ const dist = levenshtein(name, known);
20
+ if (dist > 0 && dist <= threshold) {
21
+ suspects.push({ known, distance: dist });
22
+ }
23
+ }
24
+ return suspects;
25
+ }
@@ -0,0 +1,14 @@
1
+ export function renderLicenseBadge(license) {
2
+ if (!license || license === 'None') return '';
3
+ return `![License](https://img.shields.io/badge/license-${encodeURIComponent(license)}-blue.svg)`;
4
+ }
5
+
6
+ export function renderLicenseLink(license) {
7
+ if (!license || license === 'None') return '';
8
+ return '- [License](#license)';
9
+ }
10
+
11
+ export function renderLicenseSection(license) {
12
+ if (!license || license === 'None') return '';
13
+ return `## License\nThis project is licensed under the ${license} license.`;
14
+ }
@@ -0,0 +1,20 @@
1
+ // Top npm packages by download count — used for typosquat detection
2
+ export const POPULAR_PACKAGES = [
3
+ 'lodash', 'chalk', 'react', 'express', 'axios', 'moment', 'typescript',
4
+ 'webpack', 'babel', 'eslint', 'prettier', 'jest', 'mocha', 'nodemon',
5
+ 'dotenv', 'cors', 'body-parser', 'mongoose', 'sequelize', 'prisma',
6
+ 'next', 'nuxt', 'vite', 'rollup', 'esbuild', 'turbo', 'nx',
7
+ 'inquirer', 'commander', 'yargs', 'minimist', 'ora', 'boxen', 'figlet',
8
+ 'uuid', 'nanoid', 'crypto', 'bcrypt', 'jsonwebtoken', 'passport',
9
+ 'socket.io', 'ws', 'fastify', 'koa', 'hapi', 'restify',
10
+ 'sharp', 'jimp', 'multer', 'formidable', 'busboy',
11
+ 'redis', 'ioredis', 'pg', 'mysql2', 'sqlite3', 'knex',
12
+ 'zod', 'yup', 'joi', 'ajv', 'class-validator',
13
+ 'rxjs', 'immer', 'zustand', 'redux', 'mobx', 'recoil',
14
+ 'date-fns', 'dayjs', 'luxon', 'validator', 'sanitize-html',
15
+ 'cheerio', 'puppeteer', 'playwright', 'cypress',
16
+ 'tar', 'archiver', 'adm-zip', 'node-fetch', 'got', 'superagent',
17
+ 'cross-env', 'rimraf', 'glob', 'chokidar', 'fs-extra', 'mkdirp',
18
+ 'semver', 'debug', 'winston', 'pino', 'morgan',
19
+ 'pm2', 'concurrently', 'husky', 'lint-staged', 'commitizen',
20
+ ];
@@ -0,0 +1,127 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ // Fetch registry metadata with a simple in-memory cache to avoid duplicate requests
5
+ const registryCache = new Map();
6
+
7
+ async function fetchRegistryMeta(name) {
8
+ if (registryCache.has(name)) return registryCache.get(name);
9
+ try {
10
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`);
11
+ if (!res.ok) return null;
12
+ const data = await res.json();
13
+ registryCache.set(name, data);
14
+ return data;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function parseLockfile(cwd) {
21
+ const lockPath = join(cwd, 'package-lock.json');
22
+ if (!existsSync(lockPath)) return null;
23
+ try {
24
+ return JSON.parse(readFileSync(lockPath, 'utf8'));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export async function verifyTarballIntegrity(cwd, onProgress) {
31
+ const lock = parseLockfile(cwd);
32
+ if (!lock) return { error: 'No package-lock.json found — run npm install first', findings: [] };
33
+
34
+ const packages = lock.packages || {};
35
+ const entries = Object.entries(packages).filter(
36
+ ([path, meta]) => path && path !== '' && meta.version && meta.integrity && !meta.link
37
+ );
38
+
39
+ if (entries.length === 0) return { error: null, findings: [] };
40
+
41
+ const findings = [];
42
+ let checked = 0;
43
+
44
+ // Batch requests — 5 at a time to avoid hammering the registry
45
+ const batchSize = 5;
46
+ for (let i = 0; i < entries.length; i += batchSize) {
47
+ const batch = entries.slice(i, i + batchSize);
48
+
49
+ await Promise.all(batch.map(async ([pkgPath, meta]) => {
50
+ // Handle nested node_modules (e.g. "node_modules/pkg/node_modules/dep" → "dep")
51
+ const name = pkgPath.replace(/^.*node_modules\//, '');
52
+
53
+ const registryMeta = await fetchRegistryMeta(name);
54
+ checked++;
55
+ if (onProgress) onProgress(checked, entries.length, name);
56
+
57
+ if (!registryMeta) {
58
+ findings.push({
59
+ type: 'warning',
60
+ package: name,
61
+ version: meta.version,
62
+ message: 'Could not reach registry — unable to verify',
63
+ });
64
+ return;
65
+ }
66
+
67
+ // Package not found on registry at all — highly suspicious
68
+ if (registryMeta.error === 'Not found') {
69
+ findings.push({
70
+ type: 'error',
71
+ package: name,
72
+ version: meta.version,
73
+ message: 'Package not found on npm registry — could be a private or malicious package',
74
+ });
75
+ return;
76
+ }
77
+
78
+ const versionData = registryMeta.versions?.[meta.version];
79
+ if (!versionData) {
80
+ findings.push({
81
+ type: 'warning',
82
+ package: name,
83
+ version: meta.version,
84
+ message: `Version ${meta.version} not found on registry`,
85
+ });
86
+ return;
87
+ }
88
+
89
+ const registryIntegrity = versionData.dist?.integrity;
90
+ if (!registryIntegrity) return;
91
+
92
+ // THE CORE CHECK — lock file hash vs registry hash
93
+ if (meta.integrity !== registryIntegrity) {
94
+ findings.push({
95
+ type: 'error',
96
+ package: name,
97
+ version: meta.version,
98
+ message: 'INTEGRITY MISMATCH — installed package differs from registry',
99
+ detail: {
100
+ locked: meta.integrity.slice(0, 40) + '...',
101
+ registry: registryIntegrity.slice(0, 40) + '...',
102
+ },
103
+ });
104
+ return;
105
+ }
106
+
107
+ // Check if the author/publisher changed in this version vs the previous one
108
+ const versions = Object.keys(registryMeta.versions || {});
109
+ const idx = versions.indexOf(meta.version);
110
+ if (idx > 0) {
111
+ const prevVersion = versions[idx - 1];
112
+ const prevPublisher = registryMeta.versions[prevVersion]?._npmUser?.name;
113
+ const currPublisher = versionData._npmUser?.name;
114
+ if (prevPublisher && currPublisher && prevPublisher !== currPublisher) {
115
+ findings.push({
116
+ type: 'warning',
117
+ package: name,
118
+ version: meta.version,
119
+ message: `Publisher changed from "${prevPublisher}" to "${currPublisher}" in this version`,
120
+ });
121
+ }
122
+ }
123
+ }));
124
+ }
125
+
126
+ return { error: null, findings, total: entries.length };
127
+ }
@@ -1,22 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
-
14
- - uses: actions/setup-node@v4
15
- with:
16
- node-version: '20'
17
- registry-url: 'https://registry.npmjs.org'
18
-
19
- - name: Publish
20
- run: npm publish
21
- env:
22
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}