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 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
+ [![NPM version](https://img.shields.io/npm/v/age-install.svg?style=flat)](https://www.npmjs.com/package/age-install) [![NPM downloads](https://img.shields.io/npm/dm/age-install.svg?style=flat)](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
+ };
@@ -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 };
@@ -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
+ };
@@ -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
+ };