age-install 0.1.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/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/age-install.js +442 -0
- package/package.json +37 -0
- package/src/cache.js +76 -0
- package/src/config.js +78 -0
- package/src/exclusion.js +25 -0
- package/src/registry.js +85 -0
- package/src/validator.js +102 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cinfinit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to do so, subject to the
|
|
10
|
+
following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# age-install
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/age-install) [](https://npmjs.org/package/age-install)
|
|
4
|
+
|
|
5
|
+
> Because "trust me, it's fine" isn't a security strategy.
|
|
6
|
+
|
|
7
|
+
Delay npm package installations until they reach a minimum age, protecting against supply chain attacks.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The Problem
|
|
12
|
+
|
|
13
|
+
Hackers love publishing malicious packages. You know what they love more? When those packages get taken down within an hour. So let's not install anything fresh out of the oven. Age-install waits until packages reach a certain age (in **minutes**) before letting them in.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g age-install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or ride the `npx` wave:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx age-install install react
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Install with age check (default: 60 min minimum)
|
|
31
|
+
age-install install react lodash
|
|
32
|
+
|
|
33
|
+
# Check packages WITHOUT installing (generate report)
|
|
34
|
+
age-install check react lodash
|
|
35
|
+
|
|
36
|
+
# Check ALL dependencies in package.json
|
|
37
|
+
age-install check
|
|
38
|
+
|
|
39
|
+
# Add a package (like npm add, but safer)
|
|
40
|
+
age-install add typescript
|
|
41
|
+
|
|
42
|
+
# Bypass everything (you've been warned)
|
|
43
|
+
age-install install react --force
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
|---------|-------------|
|
|
50
|
+
| `install [pkgs]` | Install packages with safety checks |
|
|
51
|
+
| `add <pkgs>` | Add packages to package.json with safety checks |
|
|
52
|
+
| `check [pkgs]` | Check packages and generate report (no install) |
|
|
53
|
+
| `exec -- <cmd>` | Run any npm command (passthrough) |
|
|
54
|
+
| `cache` | Manage timestamp cache |
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
| Flag | What it does | Default |
|
|
59
|
+
|------|-------------|---------|
|
|
60
|
+
| `-m, --minimum-age <min>` | Minimum age in minutes before installing | 60 |
|
|
61
|
+
| `-e, --exclude <pkg>` | Skip age check for these | none |
|
|
62
|
+
| `-v, --verbose` | See what age-install is thinking | false |
|
|
63
|
+
| `-f, --force` | Install without asking | false |
|
|
64
|
+
| `-r, --report` | Save report to JSON file | false |
|
|
65
|
+
| `--report-file <path>` | Custom report file path | age-install-report-YYYY-MM-DD.json |
|
|
66
|
+
| `-c, --clear` | Clear the timestamp cache | false |
|
|
67
|
+
| `-h, --help` | You're reading it | - |
|
|
68
|
+
| `-V, --version` | Spoiler: still v0.1.0 | - |
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
### package.json
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"ageInstall": {
|
|
77
|
+
"minimumReleaseAge": 60, // minutes
|
|
78
|
+
"minimumReleaseAgeExclude": ["webpack", "vite"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### .npmrc
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
age-install.minimumReleaseAge=60 # minutes
|
|
87
|
+
age-install.minimumReleaseAgeExclude=webpack,vite
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Environment
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
AGE_INSTALL_MIN_AGE=60 # minutes
|
|
94
|
+
AGE_INSTALL_EXCLUDE=webpack,vite
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Priority:** CLI args → Environment → Config file → Defaults
|
|
98
|
+
|
|
99
|
+
## Exclusion Patterns
|
|
100
|
+
|
|
101
|
+
Not everything needs the waiting room:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"ageInstall": {
|
|
106
|
+
"minimumReleaseAgeExclude": [
|
|
107
|
+
"webpack", // Exact match - webpack trusts webpack
|
|
108
|
+
"@babel/core", // Scoped packages work too
|
|
109
|
+
"^eslint", // Regex - matches eslint, eslint-config-*
|
|
110
|
+
"@types/*" // Wildcard - all @types/* get a pass
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Check Command (Report Mode)
|
|
117
|
+
|
|
118
|
+
The `check` command validates packages without installing. Perfect for CI/CD pipelines or auditing.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# Check specific packages
|
|
122
|
+
age-install check react lodash express
|
|
123
|
+
|
|
124
|
+
# Check all deps in package.json
|
|
125
|
+
age-install check
|
|
126
|
+
|
|
127
|
+
# Generate report and save to JSON file
|
|
128
|
+
age-install check react lodash --report
|
|
129
|
+
|
|
130
|
+
# Custom report file path
|
|
131
|
+
age-install check --report --report-file ./my-report.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Example console output:**
|
|
135
|
+
```
|
|
136
|
+
📋 Checking 3 package(s)...
|
|
137
|
+
|
|
138
|
+
✅ Safe to install (old enough):
|
|
139
|
+
- react@19.2.6 (207.8 hours old)
|
|
140
|
+
- lodash@4.18.1 (1043.1 hours old)
|
|
141
|
+
|
|
142
|
+
⚠️ Too new (would be blocked):
|
|
143
|
+
- express@5.0.0 (15 minutes old, min: 60 min)
|
|
144
|
+
|
|
145
|
+
⏭️ Excluded (no checks performed):
|
|
146
|
+
- webpack
|
|
147
|
+
|
|
148
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
149
|
+
📊 Summary: 2 safe, 1 blocked, 1 excluded
|
|
150
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
151
|
+
|
|
152
|
+
📄 Report saved to: age-install-report-2026-05-15.json
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Example JSON report file:**
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"generated": "2026-05-15T08:30:00.000Z",
|
|
159
|
+
"minimumAge": 60,
|
|
160
|
+
"source": "command-line",
|
|
161
|
+
"summary": {
|
|
162
|
+
"safe": 2,
|
|
163
|
+
"blocked": 1,
|
|
164
|
+
"excluded": 1,
|
|
165
|
+
"total": 4
|
|
166
|
+
},
|
|
167
|
+
"safe": [
|
|
168
|
+
{
|
|
169
|
+
"name": "react",
|
|
170
|
+
"version": "19.2.6",
|
|
171
|
+
"fullSpec": "react@19.2.6",
|
|
172
|
+
"ageMinutes": 12468,
|
|
173
|
+
"timestamp": "2026-05-06T16:16:47.653Z"
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"blocked": [
|
|
177
|
+
{
|
|
178
|
+
"name": "express",
|
|
179
|
+
"version": "5.0.0",
|
|
180
|
+
"fullSpec": "express@5.0.0",
|
|
181
|
+
"ageMinutes": 15,
|
|
182
|
+
"ageFormatted": "15 minutes",
|
|
183
|
+
"timestamp": "2026-05-15T08:15:00.000Z"
|
|
184
|
+
}
|
|
185
|
+
],
|
|
186
|
+
"excluded": [
|
|
187
|
+
{ "name": "webpack" }
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Features
|
|
193
|
+
|
|
194
|
+
- **Scoped packages?** Yup. `@babel/core`, `@types/react`, all good.
|
|
195
|
+
- **Version ranges?** Bring it. `react@^18`, `lodash@~4.17`, `express@^4`.
|
|
196
|
+
- **Partial versions?** We got you. `express@^4` resolves to the real thing.
|
|
197
|
+
- **Zero dependencies?** True story. Pure Node.js.
|
|
198
|
+
- **JSON reports?** You bet. Perfect for CI/CD artifacts.
|
|
199
|
+
|
|
200
|
+
## Why Not Just Use pnpm?
|
|
201
|
+
|
|
202
|
+
pnpm v10.16 added this natively. Nice, right? But what if you're already using npm? Or yarn? Age-install has your back across the ecosystem.
|
|
203
|
+
|
|
204
|
+
## About the Author
|
|
205
|
+
|
|
206
|
+
Built by **[cinfinit](https://github.com/cinfinit)** who's tired of the "just installed a malicious package" Slack messages at 3 AM.
|
|
207
|
+
|
|
208
|
+
This started as a "let's quickly check if any of our deps were published today" script and turned into this. If you find it useful, great. If not, at least you now know what `minimumReleaseAge` is for in pnpm.
|
|
209
|
+
|
|
210
|
+
**Made with:** VS Code, 0 caffeine, and a healthy distrust of packages published in the last hour.
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { loadConfig, mergeCliConfig } = require('../src/config');
|
|
8
|
+
const { validatePackages } = require('../src/validator');
|
|
9
|
+
|
|
10
|
+
const CWD = process.cwd();
|
|
11
|
+
const ARGS = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
const COLORS = {
|
|
14
|
+
red: (text) => `\x1b[31m${text}\x1b[0m`,
|
|
15
|
+
green: (text) => `\x1b[32m${text}\x1b[0m`,
|
|
16
|
+
yellow: (text) => `\x1b[33m${text}\x1b[0m`,
|
|
17
|
+
cyan: (text) => `\x1b[36m${text}\x1b[0m`,
|
|
18
|
+
dim: (text) => `\x1b[2m${text}\x1b[0m`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function formatAge(ageMinutes) {
|
|
22
|
+
if (ageMinutes > 60) {
|
|
23
|
+
return `${(ageMinutes / 60).toFixed(1)} hours`;
|
|
24
|
+
}
|
|
25
|
+
return `${Math.round(ageMinutes)} minutes`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printBlockedPackages(packages, minAge) {
|
|
29
|
+
for (const pkg of packages) {
|
|
30
|
+
console.log(` - ${pkg.fullSpec} - published ${formatAge(pkg.ageMinutes)} ago (min: ${minAge} min)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(args) {
|
|
35
|
+
const result = {
|
|
36
|
+
command: null,
|
|
37
|
+
packages: [],
|
|
38
|
+
options: {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const COMMANDS = ['install', 'add', 'check', 'exec', 'cache'];
|
|
42
|
+
let i = 0;
|
|
43
|
+
|
|
44
|
+
while (i < args.length) {
|
|
45
|
+
const arg = args[i];
|
|
46
|
+
|
|
47
|
+
if (arg === '--') {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg.startsWith('-')) {
|
|
52
|
+
switch (arg) {
|
|
53
|
+
case '-h':
|
|
54
|
+
case '--help':
|
|
55
|
+
result.options.help = true;
|
|
56
|
+
break;
|
|
57
|
+
case '-V':
|
|
58
|
+
case '--version':
|
|
59
|
+
result.options.version = true;
|
|
60
|
+
break;
|
|
61
|
+
case '-v':
|
|
62
|
+
case '--verbose':
|
|
63
|
+
result.options.verbose = true;
|
|
64
|
+
break;
|
|
65
|
+
case '-f':
|
|
66
|
+
case '--force':
|
|
67
|
+
result.options.force = true;
|
|
68
|
+
break;
|
|
69
|
+
case '-m':
|
|
70
|
+
case '--minimum-age':
|
|
71
|
+
result.options.minimumAge = parseInt(args[++i], 10);
|
|
72
|
+
break;
|
|
73
|
+
case '-e':
|
|
74
|
+
case '--exclude':
|
|
75
|
+
result.options.exclude = args[++i];
|
|
76
|
+
break;
|
|
77
|
+
case '-c':
|
|
78
|
+
case '--clear':
|
|
79
|
+
result.options.clear = true;
|
|
80
|
+
break;
|
|
81
|
+
case '--report':
|
|
82
|
+
result.options.report = true;
|
|
83
|
+
break;
|
|
84
|
+
case '--report-file':
|
|
85
|
+
result.options.reportFile = args[++i];
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (COMMANDS.includes(arg)) {
|
|
93
|
+
result.command = arg;
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
result.packages.push(arg);
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (result.packages.length > 0 && !result.command) {
|
|
103
|
+
result.command = 'install';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function installWithChecks(packages, options, saveFlag = false) {
|
|
110
|
+
const config = mergeCliConfig(loadConfig(CWD), options);
|
|
111
|
+
|
|
112
|
+
if (config.force) {
|
|
113
|
+
runNpm(['install', ...packages, saveFlag]);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const results = await validatePackages(packages, config, CWD);
|
|
118
|
+
const allowed = results.filter((r) => r.allowed);
|
|
119
|
+
const blocked = results.filter((r) => !r.allowed);
|
|
120
|
+
|
|
121
|
+
if (blocked.length > 0) {
|
|
122
|
+
console.error(COLORS.red('\n[X] Install blocked - unsafe packages:'));
|
|
123
|
+
printBlockedPackages(blocked, config.minimumReleaseAge);
|
|
124
|
+
console.log(COLORS.cyan('\n[?] To allow these packages:'));
|
|
125
|
+
console.log(` - Add to exclusion list: age-install install ${blocked.map((r) => r.name).join(' ')} -e ${blocked.map((r) => r.name).join(',')}`);
|
|
126
|
+
console.log(` - Lower threshold: age-install install ${packages.join(' ')} -m 0`);
|
|
127
|
+
console.log(` - Force install: age-install install ${packages.join(' ')} -f`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(COLORS.green(`\n[OK] All ${allowed.length} packages passed safety checks`));
|
|
132
|
+
runNpm(['install', ...packages, saveFlag]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function installFromPackageJson(config) {
|
|
136
|
+
const pkgPath = path.join(CWD, 'package.json');
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(pkgPath)) {
|
|
139
|
+
console.error(COLORS.red('No package.json found'));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let pkg;
|
|
144
|
+
try {
|
|
145
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
146
|
+
} catch {
|
|
147
|
+
console.error(COLORS.red('Error reading package.json'));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const deps = [
|
|
152
|
+
...Object.keys(pkg.dependencies || {}),
|
|
153
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
if (deps.length === 0) {
|
|
157
|
+
console.log(COLORS.yellow('No dependencies found in package.json'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(COLORS.cyan(`Checking ${deps.length} dependencies...`));
|
|
162
|
+
|
|
163
|
+
const packages = deps.map((dep) => {
|
|
164
|
+
const version = pkg.dependencies?.[dep] || pkg.devDependencies?.[dep] || '';
|
|
165
|
+
return version ? `${dep}@${version}` : dep;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const results = await validatePackages(packages, config, CWD);
|
|
169
|
+
|
|
170
|
+
const safe = results.filter((r) => r.allowed && r.reason !== 'excluded');
|
|
171
|
+
const blocked = results.filter((r) => !r.allowed);
|
|
172
|
+
const excluded = results.filter((r) => r.reason === 'excluded');
|
|
173
|
+
|
|
174
|
+
if (excluded.length > 0) {
|
|
175
|
+
console.log(COLORS.cyan('\n[>>] Excluded packages (no checks):'));
|
|
176
|
+
excluded.forEach((r) => console.log(` - ${r.name}`));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (blocked.length > 0) {
|
|
180
|
+
console.log(COLORS.yellow('\n[!] Skipped packages (too new):'));
|
|
181
|
+
printBlockedPackages(blocked, config.minimumReleaseAge);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const toInstall = safe.map((r) => r.fullSpec);
|
|
185
|
+
|
|
186
|
+
if (toInstall.length > 0) {
|
|
187
|
+
console.log(COLORS.green(`\n[OK] Installing ${toInstall.length} safe packages...`));
|
|
188
|
+
runNpm(['install', ...toInstall]);
|
|
189
|
+
} else {
|
|
190
|
+
console.log(COLORS.yellow('\n[--] No packages to install'));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(COLORS.cyan(`\n[STAT] ${safe.length} installed, ${blocked.length} skipped, ${excluded.length} excluded`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function checkPackages(packages, options) {
|
|
197
|
+
const config = mergeCliConfig(loadConfig(CWD), options);
|
|
198
|
+
|
|
199
|
+
let pkgsToCheck = packages;
|
|
200
|
+
let isPackageJson = false;
|
|
201
|
+
|
|
202
|
+
if (packages.length === 0) {
|
|
203
|
+
const pkgPath = path.join(CWD, 'package.json');
|
|
204
|
+
if (!fs.existsSync(pkgPath)) {
|
|
205
|
+
console.error(COLORS.red('No package.json found'));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
211
|
+
const deps = [
|
|
212
|
+
...Object.keys(pkg.dependencies || {}),
|
|
213
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
if (deps.length === 0) {
|
|
217
|
+
console.log(COLORS.yellow('No dependencies found in package.json'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
pkgsToCheck = deps.map((dep) => {
|
|
222
|
+
const version = pkg.dependencies?.[dep] || pkg.devDependencies?.[dep] || '';
|
|
223
|
+
return version ? `${dep}@${version}` : dep;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
isPackageJson = true;
|
|
227
|
+
console.log(COLORS.cyan(`\n📋 Checking ${pkgsToCheck.length} dependencies from package.json...\n`));
|
|
228
|
+
} catch {
|
|
229
|
+
console.error(COLORS.red('Error reading package.json'));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
console.log(COLORS.cyan(`\n📋 Checking ${pkgsToCheck.length} package(s)...\n`));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const results = await validatePackages(pkgsToCheck, config, CWD);
|
|
237
|
+
|
|
238
|
+
const safe = results.filter((r) => r.allowed && r.reason !== 'excluded');
|
|
239
|
+
const blocked = results.filter((r) => !r.allowed);
|
|
240
|
+
const excluded = results.filter((r) => r.reason === 'excluded');
|
|
241
|
+
|
|
242
|
+
console.log(COLORS.green('✅ Safe to install (old enough):'));
|
|
243
|
+
if (safe.length === 0) {
|
|
244
|
+
console.log(COLORS.dim(' (none)'));
|
|
245
|
+
} else {
|
|
246
|
+
safe.forEach((r) => {
|
|
247
|
+
console.log(` - ${r.fullSpec} (${formatAge(r.ageMinutes)} old)`);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(COLORS.yellow('\n⚠️ Too new (would be blocked):'));
|
|
252
|
+
if (blocked.length === 0) {
|
|
253
|
+
console.log(COLORS.dim(' (none)'));
|
|
254
|
+
} else {
|
|
255
|
+
blocked.forEach((r) => {
|
|
256
|
+
console.log(` - ${r.fullSpec} (${formatAge(r.ageMinutes)} old, min: ${config.minimumReleaseAge} min)`);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(COLORS.cyan('\n⏭️ Excluded (no checks performed):'));
|
|
261
|
+
if (excluded.length === 0) {
|
|
262
|
+
console.log(COLORS.dim(' (none)'));
|
|
263
|
+
} else {
|
|
264
|
+
excluded.forEach((r) => {
|
|
265
|
+
console.log(` - ${r.name}`);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(COLORS.cyan(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`));
|
|
270
|
+
console.log(COLORS.cyan(`📊 Summary: ${safe.length} safe, ${blocked.length} blocked, ${excluded.length} excluded`));
|
|
271
|
+
console.log(COLORS.cyan(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`));
|
|
272
|
+
|
|
273
|
+
if (!isPackageJson) {
|
|
274
|
+
console.log(COLORS.dim('\nUse age-install install <packages> to install safe packages.'));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (options.report) {
|
|
278
|
+
const reportFile = options.reportFile || generateReportFilename();
|
|
279
|
+
const reportData = generateReportData(config, safe, blocked, excluded, isPackageJson);
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
fs.writeFileSync(reportFile, JSON.stringify(reportData, null, 2));
|
|
283
|
+
console.log(COLORS.green(`\n📄 Report saved to: ${reportFile}`));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.error(COLORS.red(`\nFailed to save report: ${err.message}`));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function generateReportFilename() {
|
|
291
|
+
const now = new Date();
|
|
292
|
+
const date = now.toISOString().split('T')[0];
|
|
293
|
+
return `age-install-report-${date}.json`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function generateReportData(config, safe, blocked, excluded, isPackageJson) {
|
|
297
|
+
return {
|
|
298
|
+
generated: new Date().toISOString(),
|
|
299
|
+
minimumAge: config.minimumReleaseAge,
|
|
300
|
+
source: isPackageJson ? 'package.json' : 'command-line',
|
|
301
|
+
summary: {
|
|
302
|
+
safe: safe.length,
|
|
303
|
+
blocked: blocked.length,
|
|
304
|
+
excluded: excluded.length,
|
|
305
|
+
total: safe.length + blocked.length + excluded.length,
|
|
306
|
+
},
|
|
307
|
+
safe: safe.map((r) => ({
|
|
308
|
+
name: r.name,
|
|
309
|
+
version: r.version,
|
|
310
|
+
fullSpec: r.fullSpec,
|
|
311
|
+
ageMinutes: Math.round(r.ageMinutes),
|
|
312
|
+
timestamp: r.timestamp,
|
|
313
|
+
})),
|
|
314
|
+
blocked: blocked.map((r) => ({
|
|
315
|
+
name: r.name,
|
|
316
|
+
version: r.version,
|
|
317
|
+
fullSpec: r.fullSpec,
|
|
318
|
+
ageMinutes: Math.round(r.ageMinutes),
|
|
319
|
+
ageFormatted: formatAge(r.ageMinutes),
|
|
320
|
+
timestamp: r.timestamp,
|
|
321
|
+
})),
|
|
322
|
+
excluded: excluded.map((r) => ({
|
|
323
|
+
name: r.name,
|
|
324
|
+
})),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function runNpm(args) {
|
|
329
|
+
try {
|
|
330
|
+
execSync(`npm ${args.join(' ')}`, { stdio: 'inherit' });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
process.exit(err.status || 1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function showHelp() {
|
|
337
|
+
console.log(`
|
|
338
|
+
age-install - Delay npm package installations until they reach a minimum age
|
|
339
|
+
|
|
340
|
+
Usage:
|
|
341
|
+
age-install [command] [options] [packages...]
|
|
342
|
+
|
|
343
|
+
Commands:
|
|
344
|
+
install [packages] Install packages with safety checks
|
|
345
|
+
add <packages> Add packages to package.json with safety checks
|
|
346
|
+
check [packages] Check packages without installing (generate report)
|
|
347
|
+
exec -- <cmd> Run npm command (passthrough - no safety checks)
|
|
348
|
+
cache Manage timestamp cache
|
|
349
|
+
|
|
350
|
+
Options:
|
|
351
|
+
-m, --minimum-age <minutes> Minimum age before installing (default: 60)
|
|
352
|
+
-e, --exclude <packages> Packages to exclude from checks
|
|
353
|
+
-v, --verbose Show detailed output
|
|
354
|
+
-f, --force Skip all safety checks
|
|
355
|
+
-r, --report Save report to JSON file
|
|
356
|
+
--report-file <path> Custom report file path
|
|
357
|
+
-h, --help Show this help message
|
|
358
|
+
-V, --version Show version
|
|
359
|
+
|
|
360
|
+
Examples:
|
|
361
|
+
age-install install react lodash
|
|
362
|
+
age-install check react lodash # Generate report without installing
|
|
363
|
+
age-install check # Check all deps in package.json
|
|
364
|
+
age-install check --report # Save report to age-install-report-YYYY-MM-DD.json
|
|
365
|
+
age-install check --report --report-file ./my-report.json
|
|
366
|
+
age-install add typescript
|
|
367
|
+
age-install cache --clear
|
|
368
|
+
`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function main() {
|
|
372
|
+
const parsed = parseArgs(ARGS);
|
|
373
|
+
|
|
374
|
+
if (parsed.options.help) {
|
|
375
|
+
showHelp();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (parsed.options.version) {
|
|
380
|
+
console.log('age-install v0.1.0');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!parsed.command) {
|
|
385
|
+
if (parsed.packages.length === 0) {
|
|
386
|
+
console.log(COLORS.yellow('No packages specified. Checking package.json dependencies...'));
|
|
387
|
+
await installFromPackageJson(mergeCliConfig(loadConfig(CWD), parsed.options));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
parsed.command = 'install';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
switch (parsed.command) {
|
|
394
|
+
case 'install':
|
|
395
|
+
if (parsed.packages.length === 0) {
|
|
396
|
+
console.log(COLORS.yellow('No packages specified. Checking package.json dependencies...'));
|
|
397
|
+
await installFromPackageJson(mergeCliConfig(loadConfig(CWD), parsed.options));
|
|
398
|
+
} else {
|
|
399
|
+
await installWithChecks(parsed.packages, parsed.options);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case 'add':
|
|
403
|
+
await installWithChecks(parsed.packages, parsed.options, '--save');
|
|
404
|
+
break;
|
|
405
|
+
case 'check':
|
|
406
|
+
await checkPackages(parsed.packages, parsed.options);
|
|
407
|
+
break;
|
|
408
|
+
case 'exec':
|
|
409
|
+
runNpm(ARGS.slice(ARGS.indexOf('--') + 1));
|
|
410
|
+
break;
|
|
411
|
+
case 'cache':
|
|
412
|
+
handleCache(parsed.options);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleCache(options) {
|
|
418
|
+
const cacheFile = path.join(CWD, 'node_modules', '.age-install-cache', 'timestamps.json');
|
|
419
|
+
|
|
420
|
+
if (options.clear) {
|
|
421
|
+
if (fs.existsSync(cacheFile)) {
|
|
422
|
+
fs.unlinkSync(cacheFile);
|
|
423
|
+
console.log(COLORS.green('Cache cleared successfully'));
|
|
424
|
+
} else {
|
|
425
|
+
console.log(COLORS.yellow('Cache is already empty'));
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (fs.existsSync(cacheFile)) {
|
|
431
|
+
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
432
|
+
console.log(COLORS.cyan(`\n[PKG] Cached packages: ${Object.keys(cache).length}`));
|
|
433
|
+
console.log(COLORS.dim('(Use age-install cache --clear to clear)'));
|
|
434
|
+
} else {
|
|
435
|
+
console.log(COLORS.yellow('No cache found'));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
main().catch((err) => {
|
|
440
|
+
console.error(COLORS.red(`Error: ${err.message}`));
|
|
441
|
+
process.exit(1);
|
|
442
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "age-install",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Delay npm package installations until they reach a minimum age, protecting against supply chain attacks",
|
|
5
|
+
"bin": {
|
|
6
|
+
"age-install": "./bin/age-install.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node bin/age-install.js",
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"npm",
|
|
14
|
+
"security",
|
|
15
|
+
"supply-chain",
|
|
16
|
+
"delayed-dependencies",
|
|
17
|
+
"package-security",
|
|
18
|
+
"dependency-safety",
|
|
19
|
+
"malicious-packages",
|
|
20
|
+
"install-safe",
|
|
21
|
+
"ci-cd",
|
|
22
|
+
"audit"
|
|
23
|
+
],
|
|
24
|
+
"author": "cinfinit",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/cinfinit/age-install/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/cinfinit/age-install#readme",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/cinfinit/age-install.git"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CACHE_DIR = '.age-install-cache';
|
|
5
|
+
const CACHE_FILE = 'timestamps.json';
|
|
6
|
+
|
|
7
|
+
function getCacheDir(cwd = process.cwd()) {
|
|
8
|
+
return path.join(cwd, 'node_modules', CACHE_DIR);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getCacheFilePath(cwd = process.cwd()) {
|
|
12
|
+
return path.join(getCacheDir(cwd), CACHE_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadCache(cwd = process.cwd()) {
|
|
16
|
+
const cachePath = getCacheFilePath(cwd);
|
|
17
|
+
if (!fs.existsSync(cachePath)) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveCache(cache, cwd = process.cwd()) {
|
|
28
|
+
const cacheDir = getCacheDir(cwd);
|
|
29
|
+
const cachePath = getCacheFilePath(cwd);
|
|
30
|
+
|
|
31
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getFromCache(pkg, cwd = process.cwd()) {
|
|
36
|
+
const cache = loadCache(cwd);
|
|
37
|
+
return cache[pkg] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setInCache(pkg, timestamp, cwd = process.cwd()) {
|
|
41
|
+
const cache = loadCache(cwd);
|
|
42
|
+
cache[pkg] = timestamp;
|
|
43
|
+
saveCache(cache, cwd);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getMultipleFromCache(packages, cwd = process.cwd()) {
|
|
47
|
+
const cache = loadCache(cwd);
|
|
48
|
+
const results = {};
|
|
49
|
+
|
|
50
|
+
for (const pkg of packages) {
|
|
51
|
+
results[pkg] = cache[pkg] || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setMultipleInCache(entries, cwd = process.cwd()) {
|
|
58
|
+
const cache = loadCache(cwd);
|
|
59
|
+
|
|
60
|
+
for (const [pkg, timestamp] of Object.entries(entries)) {
|
|
61
|
+
cache[pkg] = timestamp;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
saveCache(cache, cwd);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
getCacheDir,
|
|
69
|
+
getCacheFilePath,
|
|
70
|
+
loadCache,
|
|
71
|
+
saveCache,
|
|
72
|
+
getFromCache,
|
|
73
|
+
setInCache,
|
|
74
|
+
getMultipleFromCache,
|
|
75
|
+
setMultipleInCache,
|
|
76
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MIN_AGE = 60;
|
|
5
|
+
|
|
6
|
+
function loadConfig(cwd = process.cwd()) {
|
|
7
|
+
const config = {
|
|
8
|
+
minimumReleaseAge: DEFAULT_MIN_AGE,
|
|
9
|
+
minimumReleaseAgeExclude: [],
|
|
10
|
+
verbose: false,
|
|
11
|
+
force: false,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
15
|
+
if (fs.existsSync(pkgPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
18
|
+
if (pkg.ageInstall) {
|
|
19
|
+
if (typeof pkg.ageInstall.minimumReleaseAge === 'number') {
|
|
20
|
+
config.minimumReleaseAge = pkg.ageInstall.minimumReleaseAge;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(pkg.ageInstall.minimumReleaseAgeExclude)) {
|
|
23
|
+
config.minimumReleaseAgeExclude = [...pkg.ageInstall.minimumReleaseAgeExclude];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore parse errors
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const npmrcPath = path.join(cwd, '.npmrc');
|
|
32
|
+
if (fs.existsSync(npmrcPath)) {
|
|
33
|
+
const content = fs.readFileSync(npmrcPath, 'utf8');
|
|
34
|
+
const minAgeMatch = content.match(/age-install\.minimumReleaseAge=(\d+)/);
|
|
35
|
+
if (minAgeMatch) {
|
|
36
|
+
config.minimumReleaseAge = parseInt(minAgeMatch[1], 10);
|
|
37
|
+
}
|
|
38
|
+
const excludeMatch = content.match(/age-install\.minimumReleaseAgeExclude=(.+)/);
|
|
39
|
+
if (excludeMatch) {
|
|
40
|
+
config.minimumReleaseAgeExclude = excludeMatch[1].split(',').map((s) => s.trim());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.env.AGE_INSTALL_MIN_AGE) {
|
|
45
|
+
config.minimumReleaseAge = parseInt(process.env.AGE_INSTALL_MIN_AGE, 10);
|
|
46
|
+
}
|
|
47
|
+
if (process.env.AGE_INSTALL_EXCLUDE) {
|
|
48
|
+
config.minimumReleaseAgeExclude = process.env.AGE_INSTALL_EXCLUDE.split(',').map((s) => s.trim());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mergeCliConfig(config, cliOptions = {}) {
|
|
55
|
+
const merged = { ...config };
|
|
56
|
+
|
|
57
|
+
if (cliOptions.minimumAge !== undefined) {
|
|
58
|
+
merged.minimumReleaseAge = cliOptions.minimumAge;
|
|
59
|
+
}
|
|
60
|
+
if (cliOptions.exclude) {
|
|
61
|
+
const excludes = cliOptions.exclude.split(',').map((s) => s.trim());
|
|
62
|
+
merged.minimumReleaseAgeExclude = [...merged.minimumReleaseAgeExclude, ...excludes];
|
|
63
|
+
}
|
|
64
|
+
if (cliOptions.verbose) {
|
|
65
|
+
merged.verbose = true;
|
|
66
|
+
}
|
|
67
|
+
if (cliOptions.force) {
|
|
68
|
+
merged.force = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
loadConfig,
|
|
76
|
+
mergeCliConfig,
|
|
77
|
+
DEFAULT_MIN_AGE,
|
|
78
|
+
};
|
package/src/exclusion.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function isExcluded(packageName, exclusions) {
|
|
2
|
+
if (!exclusions || exclusions.length === 0) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
for (const pattern of exclusions) {
|
|
7
|
+
if (pattern.startsWith('^')) {
|
|
8
|
+
const regex = new RegExp(pattern.slice(1));
|
|
9
|
+
if (regex.test(packageName)) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
} else if (pattern.includes('*')) {
|
|
13
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
14
|
+
if (regex.test(packageName)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
} else if (packageName === pattern) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { isExcluded };
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
const NPM_TIMEOUT = 15000;
|
|
4
|
+
|
|
5
|
+
function execNpmView(args) {
|
|
6
|
+
return execSync(`npm view ${args.join(' ')}`, {
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
timeout: NPM_TIMEOUT,
|
|
9
|
+
}).trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parsePackageSpec(packageSpec) {
|
|
13
|
+
if (packageSpec.startsWith('@')) {
|
|
14
|
+
const atIndex = packageSpec.indexOf('@', 1);
|
|
15
|
+
if (atIndex === -1) {
|
|
16
|
+
return { name: packageSpec, version: null };
|
|
17
|
+
}
|
|
18
|
+
const name = packageSpec.slice(0, atIndex);
|
|
19
|
+
let version = packageSpec.slice(atIndex + 1);
|
|
20
|
+
if (version === '' || version === 'latest') {
|
|
21
|
+
version = null;
|
|
22
|
+
}
|
|
23
|
+
return { name, version };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const atIndex = packageSpec.indexOf('@');
|
|
27
|
+
if (atIndex === -1) {
|
|
28
|
+
return { name: packageSpec, version: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const name = packageSpec.slice(0, atIndex);
|
|
32
|
+
let version = packageSpec.slice(atIndex + 1);
|
|
33
|
+
|
|
34
|
+
if (version === '' || version === 'latest') {
|
|
35
|
+
version = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { name, version };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveVersion(packageSpec) {
|
|
42
|
+
const { name, version } = parsePackageSpec(packageSpec);
|
|
43
|
+
|
|
44
|
+
if (!version) {
|
|
45
|
+
return execNpmView([name, 'version']);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (version.includes('*') || version.includes('^') || version.includes('~') || version.includes('>')) {
|
|
49
|
+
const result = execNpmView([`${name}@${version}`, 'version']);
|
|
50
|
+
return result.split('\n').pop().trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (/^\d+$/.test(version)) {
|
|
54
|
+
return execNpmView([name, 'version']);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return version;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getVersionTime(packageName, requestedVersion) {
|
|
61
|
+
const targetVersion = requestedVersion || execNpmView([packageName, 'version']);
|
|
62
|
+
const timeData = JSON.parse(execNpmView([packageName, 'time', '--json']));
|
|
63
|
+
|
|
64
|
+
if (timeData[targetVersion]) {
|
|
65
|
+
return { version: targetVersion, timestamp: timeData[targetVersion] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const latestVersion = execNpmView([packageName, 'version']);
|
|
69
|
+
return {
|
|
70
|
+
version: latestVersion,
|
|
71
|
+
timestamp: timeData[latestVersion] || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getPackageInfo(packageName) {
|
|
76
|
+
const output = execNpmView([packageName, '--json']);
|
|
77
|
+
return JSON.parse(output);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
parsePackageSpec,
|
|
82
|
+
resolveVersion,
|
|
83
|
+
getVersionTime,
|
|
84
|
+
getPackageInfo,
|
|
85
|
+
};
|
package/src/validator.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const cache = require('./cache');
|
|
2
|
+
const registry = require('./registry');
|
|
3
|
+
const exclusion = require('./exclusion');
|
|
4
|
+
|
|
5
|
+
function getAgeInMinutes(timestamp) {
|
|
6
|
+
if (!timestamp) {
|
|
7
|
+
return Infinity;
|
|
8
|
+
}
|
|
9
|
+
return (Date.now() - new Date(timestamp).getTime()) / 1000 / 60;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isSafe(timestamp, minAge) {
|
|
13
|
+
return getAgeInMinutes(timestamp) >= minAge;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function validatePackage(packageSpec, config, cwd) {
|
|
17
|
+
const { name, version: requestedVersion } = registry.parsePackageSpec(packageSpec);
|
|
18
|
+
const resolvedVersion = registry.resolveVersion(packageSpec);
|
|
19
|
+
const fullSpec = `${name}@${resolvedVersion}`;
|
|
20
|
+
|
|
21
|
+
if (exclusion.isExcluded(name, config.minimumReleaseAgeExclude)) {
|
|
22
|
+
if (config.verbose) {
|
|
23
|
+
console.log(` [>>] ${name} - excluded from checks`);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
version: resolvedVersion,
|
|
28
|
+
fullSpec,
|
|
29
|
+
allowed: true,
|
|
30
|
+
reason: 'excluded',
|
|
31
|
+
timestamp: null,
|
|
32
|
+
ageMinutes: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cached = cache.getFromCache(fullSpec, cwd);
|
|
37
|
+
let timestamp = cached;
|
|
38
|
+
let version = resolvedVersion;
|
|
39
|
+
|
|
40
|
+
if (!cached) {
|
|
41
|
+
if (config.verbose) {
|
|
42
|
+
console.log(` [..] ${name} - querying registry...`);
|
|
43
|
+
}
|
|
44
|
+
const info = registry.getVersionTime(name, resolvedVersion);
|
|
45
|
+
timestamp = info.timestamp;
|
|
46
|
+
version = info.version;
|
|
47
|
+
|
|
48
|
+
if (timestamp) {
|
|
49
|
+
cache.setInCache(`${name}@${version}`, timestamp, cwd);
|
|
50
|
+
}
|
|
51
|
+
} else if (config.verbose) {
|
|
52
|
+
console.log(` [OK] ${name} - cache hit`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!timestamp) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
version,
|
|
59
|
+
fullSpec: `${name}@${version}`,
|
|
60
|
+
allowed: true,
|
|
61
|
+
reason: 'no_timestamp',
|
|
62
|
+
timestamp: null,
|
|
63
|
+
ageMinutes: Infinity,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ageMinutes = getAgeInMinutes(timestamp);
|
|
68
|
+
|
|
69
|
+
if (config.verbose) {
|
|
70
|
+
const ageStr = ageMinutes > 60
|
|
71
|
+
? `${(ageMinutes / 60).toFixed(1)} hours`
|
|
72
|
+
: `${Math.round(ageMinutes)} minutes`;
|
|
73
|
+
console.log(` [AGE] ${name}@${version} - published ${ageStr} ago`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const safe = isSafe(timestamp, config.minimumReleaseAge);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
version,
|
|
81
|
+
fullSpec: `${name}@${version}`,
|
|
82
|
+
allowed: safe,
|
|
83
|
+
reason: safe ? 'age_ok' : 'too_new',
|
|
84
|
+
timestamp,
|
|
85
|
+
ageMinutes,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function validatePackages(packages, config, cwd) {
|
|
90
|
+
const results = [];
|
|
91
|
+
for (const pkg of packages) {
|
|
92
|
+
results.push(await validatePackage(pkg, config, cwd));
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
getAgeInMinutes,
|
|
99
|
+
isSafe,
|
|
100
|
+
validatePackage,
|
|
101
|
+
validatePackages,
|
|
102
|
+
};
|