npm-time-machine-cli 1.0.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 +213 -0
- package/bin/ntm.js +115 -0
- package/package.json +48 -0
- package/src/cache.js +11 -0
- package/src/config.js +21 -0
- package/src/installer.js +25 -0
- package/src/proxy.js +108 -0
- package/src/reset.js +13 -0
- package/src/verify.js +123 -0
- package/src/version-filter.js +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marco Lo Pinto
|
|
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 whom the Software is
|
|
10
|
+
furnished to do so, subject to the 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,213 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/MarcoLoPinto/npm-time-machine-cli/main/assets/logo.png" width="300"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">NTM: NPM Time Machine</h1>
|
|
6
|
+
|
|
7
|
+
Reproduce your npm dependency tree as it existed at a specific point in time.
|
|
8
|
+
|
|
9
|
+
## ๐ Why?
|
|
10
|
+
|
|
11
|
+
Supply chain attacks and breaking changes often come from newly published versions of dependencies.
|
|
12
|
+
|
|
13
|
+
`npm-time-machine-cli` lets you:
|
|
14
|
+
|
|
15
|
+
- Install dependencies as they existed in the past
|
|
16
|
+
- Avoid recently introduced malicious or unstable versions
|
|
17
|
+
- Reproduce old environments reliably
|
|
18
|
+
|
|
19
|
+
## โก Features
|
|
20
|
+
|
|
21
|
+
- ๐ Time-based dependency resolution
|
|
22
|
+
- ๐ฆ Works with all dependencies (including sub-dependencies)
|
|
23
|
+
- ๐ก๏ธ Reduces exposure to recent supply chain attacks
|
|
24
|
+
- ๐ Verify installed packages against a date
|
|
25
|
+
- ๐งน Reset project state easily
|
|
26
|
+
|
|
27
|
+
## ๐ฆ Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g npm-time-machine-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or use with `npx`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx npm-time-machine-cli <command>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## ๐ฏ Usage
|
|
40
|
+
|
|
41
|
+
### 1๏ธโฃ Set Target Date
|
|
42
|
+
|
|
43
|
+
First, specify the date you want to freeze dependencies to:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ntm set 2024-01-15
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This saves your target date to `.npm-time-machine/config.json`.
|
|
50
|
+
|
|
51
|
+
### 2๏ธโฃ Install Dependencies
|
|
52
|
+
|
|
53
|
+
Install packages using the frozen date:
|
|
54
|
+
|
|
55
|
+
**Install all dependencies from `package.json`:**
|
|
56
|
+
```bash
|
|
57
|
+
ntm install
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Install specific packages:**
|
|
61
|
+
```bash
|
|
62
|
+
ntm install express lodash
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Only versions published **before or on** the target date will be installed.
|
|
66
|
+
|
|
67
|
+
**Options:**
|
|
68
|
+
- `--fallback` - If no version exists before the date, use the oldest available version
|
|
69
|
+
```bash
|
|
70
|
+
ntm install --fallback
|
|
71
|
+
```
|
|
72
|
+
- `--allow-prerelease` - Include pre-release versions (e.g., alpha, beta, rc) in version resolution
|
|
73
|
+
```bash
|
|
74
|
+
ntm install --allow-prerelease
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 3๏ธโฃ Verify Packages
|
|
78
|
+
|
|
79
|
+
Check if your `package-lock.json` matches the target date:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
ntm verify
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This will warn you about any packages installed **after** your target date.
|
|
86
|
+
|
|
87
|
+
**Override date temporarily:**
|
|
88
|
+
```bash
|
|
89
|
+
ntm verify 2023-06-01
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 4๏ธโฃ Reset Configuration
|
|
93
|
+
|
|
94
|
+
Remove ntm configuration:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
ntm reset
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## ๐ Examples
|
|
101
|
+
|
|
102
|
+
### Scenario 1: Install dependencies from 2 years ago
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ntm set 2024-01-01
|
|
106
|
+
ntm install
|
|
107
|
+
# Your project now has all dependencies as they existed on Jan 1, 2024
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Scenario 2: Use fallback for legacy packages
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ntm set 2020-05-15
|
|
114
|
+
ntm install --fallback
|
|
115
|
+
# If a package didn't exist by May 2020, uses its oldest version
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Scenario 3: Verify a historic lock file
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
ntm verify 2023-12-31
|
|
122
|
+
# Checks if package-lock.json complies with Dec 31, 2023 timeline
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## ๐ง How It Works
|
|
126
|
+
|
|
127
|
+
1. **Proxy Server** - Starts a local npm registry proxy on a random port
|
|
128
|
+
2. **Version Filtering** - Intercepts npm requests and filters available versions by publish date
|
|
129
|
+
3. **Registry Redirect** - npm CLI uses the proxy instead of the real registry
|
|
130
|
+
4. **Caching** - Responses are cached to reduce registry calls
|
|
131
|
+
5. **Cleanup** - Proxy automatically closes after installation
|
|
132
|
+
|
|
133
|
+
### Strict vs Fallback Mode
|
|
134
|
+
|
|
135
|
+
- **Strict (default)** - Fails if no version exists before the target date
|
|
136
|
+
- **Fallback** - Uses the oldest available version as a last resort
|
|
137
|
+
|
|
138
|
+
## โ ๏ธ Important Notes
|
|
139
|
+
|
|
140
|
+
- **Requires Node 18+** - Uses ES modules
|
|
141
|
+
- **Local Proxy** - Creates a temporary local server (doesn't modify global npm config)
|
|
142
|
+
- **Package Lock** - Works best with existing `package-lock.json` (or `npm-shrinkwrap.json`)
|
|
143
|
+
- **Offline** - Still requires internet to fetch package metadata
|
|
144
|
+
- **Transitive Dependencies** - Automatically handles sub-dependencies
|
|
145
|
+
|
|
146
|
+
## ๐ Troubleshooting
|
|
147
|
+
|
|
148
|
+
### "No config found. Run 'ntm set <date>' first"
|
|
149
|
+
You haven't set a target date yet. Run:
|
|
150
|
+
```bash
|
|
151
|
+
ntm set YYYY-MM-DD
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### "No versions available before selected date"
|
|
155
|
+
The package didn't exist on that date. Use:
|
|
156
|
+
```bash
|
|
157
|
+
ntm install --fallback
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### "npm install failed"
|
|
161
|
+
The proxy closed unexpectedly. Check:
|
|
162
|
+
- Internet connection
|
|
163
|
+
- npm is properly installed
|
|
164
|
+
- No processes hogging ports
|
|
165
|
+
|
|
166
|
+
### Port-related errors
|
|
167
|
+
The proxy randomly selects ports. If you get port errors, try againโit should work on retry.
|
|
168
|
+
|
|
169
|
+
## ๐ Commands Reference
|
|
170
|
+
|
|
171
|
+
| Command | Purpose |
|
|
172
|
+
|---------|---------|
|
|
173
|
+
| `ntm set <date>` | Set target date (YYYY-MM-DD format) |
|
|
174
|
+
| `ntm install [packages...]` | Install with frozen timeline |
|
|
175
|
+
| `ntm install --fallback` | Install with fallback mode enabled |
|
|
176
|
+
| `ntm install --allow-prerelease` | Install including pre-release versions |
|
|
177
|
+
| `ntm verify [date]` | Verify packages match a date |
|
|
178
|
+
| `ntm reset` | Remove ntm configuration |
|
|
179
|
+
|
|
180
|
+
## ๐ก๏ธ Security Considerations
|
|
181
|
+
|
|
182
|
+
- **Supply Chain Protection**: Lock dependencies to a known-safe date before attacks occurred
|
|
183
|
+
- **Audit Checking**: Always run `npm audit` after installation
|
|
184
|
+
- **Trust Verification**: Verify publication dates match expectations
|
|
185
|
+
- **Locked Dependencies**: Use `npm ci` instead of `npm install` in CI/CD
|
|
186
|
+
|
|
187
|
+
## ๐ค Author
|
|
188
|
+
|
|
189
|
+
Marco Lo Pinto
|
|
190
|
+
|
|
191
|
+
## ๐ค Contributing
|
|
192
|
+
|
|
193
|
+
Contributions welcome! Feel free to open issues or submit pull requests on GitHub.
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
## โก Quick Start
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# 1. Install globally
|
|
200
|
+
npm install -g npm-time-machine-cli
|
|
201
|
+
|
|
202
|
+
# 2. Set a date in your project
|
|
203
|
+
cd your-project
|
|
204
|
+
ntm set 2024-06-01
|
|
205
|
+
|
|
206
|
+
# 3. Install dependencies
|
|
207
|
+
ntm install
|
|
208
|
+
|
|
209
|
+
# 4. Verify everything is correct
|
|
210
|
+
ntm verify
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
You now have a reproducible, time-locked dependency tree! ๐
|
package/bin/ntm.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { saveConfig, loadConfig } from "../src/config.js";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version } = require("../package.json");
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name("ntm")
|
|
13
|
+
.description("npm time machine")
|
|
14
|
+
.version(version);
|
|
15
|
+
|
|
16
|
+
// SET
|
|
17
|
+
program
|
|
18
|
+
.command("set")
|
|
19
|
+
.argument("<date>", "Target date (YYYY-MM-DD)")
|
|
20
|
+
.description("Set the target date for dependency resolution")
|
|
21
|
+
.action((date) => {
|
|
22
|
+
const targetDate = new Date(date);
|
|
23
|
+
|
|
24
|
+
if (isNaN(targetDate)) {
|
|
25
|
+
console.error("โ Invalid date");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
saveConfig({ date });
|
|
30
|
+
console.log(`โ Date set to ${date}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// INSTALL
|
|
34
|
+
program
|
|
35
|
+
.command("install")
|
|
36
|
+
.argument("[packages...]", "Packages to install")
|
|
37
|
+
.option("--fallback", "Allow fallback to oldest version if none match date")
|
|
38
|
+
.option("--allow-prerelease", "Include pre-release versions in version resolution")
|
|
39
|
+
.description("Install dependencies using frozen time")
|
|
40
|
+
.action(async (packages = [], options) => {
|
|
41
|
+
try {
|
|
42
|
+
const { startProxy } = await import("../src/proxy.js");
|
|
43
|
+
const { runInstall } = await import("../src/installer.js");
|
|
44
|
+
|
|
45
|
+
const { date } = loadConfig();
|
|
46
|
+
const targetDate = new Date(date);
|
|
47
|
+
|
|
48
|
+
console.log(`โณ Installing with frozen date ${date}`);
|
|
49
|
+
console.log(`โ๏ธ Mode: ${options.fallback ? "fallback" : "strict"}${options.allowPrerelease ? ", prerelease enabled" : ""}`);
|
|
50
|
+
|
|
51
|
+
const proxy = await startProxy(targetDate, {
|
|
52
|
+
allowFallback: options.fallback || false,
|
|
53
|
+
allowPrerelease: options.allowPrerelease || false
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const args = ["install"];
|
|
58
|
+
|
|
59
|
+
if (packages.length > 0) {
|
|
60
|
+
args.push(...packages);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await runInstall(proxy.port, args);
|
|
64
|
+
|
|
65
|
+
} finally {
|
|
66
|
+
proxy.close();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`โ ${err.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// VERIFY
|
|
76
|
+
program
|
|
77
|
+
.command("verify")
|
|
78
|
+
.argument("[date]", "Optional date override")
|
|
79
|
+
.description("Verify installed dependencies against a date")
|
|
80
|
+
.action(async (dateArg) => {
|
|
81
|
+
try {
|
|
82
|
+
const { verifyProject } = await import("../src/verify.js");
|
|
83
|
+
|
|
84
|
+
let date;
|
|
85
|
+
|
|
86
|
+
if (dateArg) {
|
|
87
|
+
date = new Date(dateArg);
|
|
88
|
+
} else {
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
date = new Date(config.date);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (isNaN(date)) {
|
|
94
|
+
console.error("โ Invalid date");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await verifyProject(date);
|
|
99
|
+
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(`โ ${err.message}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// RESET
|
|
107
|
+
program
|
|
108
|
+
.command("reset")
|
|
109
|
+
.description("Remove ntm configuration")
|
|
110
|
+
.action(async () => {
|
|
111
|
+
const { resetProject } = await import("../src/reset.js");
|
|
112
|
+
await resetProject();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "npm-time-machine-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Install npm dependencies as they existed at a given date",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ntm": "bin/ntm.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test tests/**/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"author": "Marco Lo Pinto",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MarcoLoPinto/npm-time-machine-cli.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/MarcoLoPinto/npm-time-machine-cli#readme",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^11.0.0",
|
|
21
|
+
"express": "^4.18.2",
|
|
22
|
+
"node-fetch": "^3.3.2",
|
|
23
|
+
"semver": "^7.6.0"
|
|
24
|
+
},
|
|
25
|
+
"preferGlobal": true,
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"npm",
|
|
37
|
+
"dependencies",
|
|
38
|
+
"time",
|
|
39
|
+
"reproducibility",
|
|
40
|
+
"deterministic",
|
|
41
|
+
"lockfile",
|
|
42
|
+
"versioning",
|
|
43
|
+
"supply-chain",
|
|
44
|
+
"security",
|
|
45
|
+
"cli",
|
|
46
|
+
"dev-tools"
|
|
47
|
+
]
|
|
48
|
+
}
|
package/src/cache.js
ADDED
package/src/config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const dir = path.join(process.cwd(), ".npm-time-machine");
|
|
5
|
+
const file = path.join(dir, "config.json");
|
|
6
|
+
|
|
7
|
+
export function saveConfig(config) {
|
|
8
|
+
if (!fs.existsSync(dir)) {
|
|
9
|
+
fs.mkdirSync(dir);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
if (!fs.existsSync(file)) {
|
|
17
|
+
throw new Error("No ntm config found. Run 'ntm set <date>' first.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return JSON.parse(fs.readFileSync(file));
|
|
21
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
export function getNpmCommand() {
|
|
4
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function runInstall(port, args = ["install"]) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const command = getNpmCommand();
|
|
10
|
+
const child = spawn(
|
|
11
|
+
command,
|
|
12
|
+
[...args, "--registry", `http://localhost:${port}`],
|
|
13
|
+
{ stdio: "inherit" }
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
child.on("error", (error) => {
|
|
17
|
+
reject(error);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
child.on("close", (code) => {
|
|
21
|
+
if (code === 0) resolve();
|
|
22
|
+
else reject(new Error("npm install failed"));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import fetch from "node-fetch";
|
|
3
|
+
import { getCache, setCache } from "./cache.js";
|
|
4
|
+
import { filterVersionsByDate, updateDistTags } from "./version-filter.js";
|
|
5
|
+
import { pipeline } from "stream";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
|
|
8
|
+
const streamPipeline = promisify(pipeline);
|
|
9
|
+
|
|
10
|
+
export async function startProxy(targetDate, options = {}) {
|
|
11
|
+
const { allowFallback = false, allowPrerelease = false } = options;
|
|
12
|
+
|
|
13
|
+
const app = express();
|
|
14
|
+
|
|
15
|
+
app.get("/*", async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const url = `https://registry.npmjs.org${req.url}`;
|
|
18
|
+
|
|
19
|
+
// Check if this is a tarball request
|
|
20
|
+
if (req.url.endsWith(".tgz")) {
|
|
21
|
+
const response = await fetch(url);
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
return res.status(response.status).send("Upstream error");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
res.status(response.status);
|
|
28
|
+
|
|
29
|
+
const excludedHeaders = ["content-encoding", "transfer-encoding"];
|
|
30
|
+
|
|
31
|
+
response.headers.forEach((value, key) => {
|
|
32
|
+
if (!excludedHeaders.includes(key.toLowerCase())) {
|
|
33
|
+
res.setHeader(key, value);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await streamPipeline(response.body, res);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error("Stream error:", err.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Metadata request (JSON)
|
|
47
|
+
const cached = getCache(req.url);
|
|
48
|
+
if (cached) {
|
|
49
|
+
return res.json(cached);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const response = await fetch(url);
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
return res.status(response.status).send("Upstream error");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
|
|
60
|
+
// filter versions
|
|
61
|
+
const filterResult = filterVersionsByDate(data, targetDate, {
|
|
62
|
+
allowFallback,
|
|
63
|
+
allowPrerelease
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (filterResult === null) {
|
|
67
|
+
console.error(
|
|
68
|
+
`โ No versions available before ${targetDate.toISOString()} for ${req.url}`
|
|
69
|
+
);
|
|
70
|
+
return res
|
|
71
|
+
.status(404)
|
|
72
|
+
.send("No valid versions before selected date");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (filterResult.fallback) {
|
|
76
|
+
console.warn(`โ No versions before date for ${req.url}`);
|
|
77
|
+
console.warn(`โ Fallback to oldest version: ${filterResult.fallbackVersion}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Update data with filtered versions
|
|
81
|
+
data.versions = filterResult.versions;
|
|
82
|
+
|
|
83
|
+
// Update dist-tags
|
|
84
|
+
updateDistTags(data, filterResult);
|
|
85
|
+
|
|
86
|
+
// set cache
|
|
87
|
+
setCache(req.url, data);
|
|
88
|
+
|
|
89
|
+
res.json(data);
|
|
90
|
+
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`โ Proxy error for ${req.url}:`, err.message);
|
|
93
|
+
|
|
94
|
+
return res.status(502).send("Bad gateway");
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const server = app.listen(0);
|
|
99
|
+
const port = server.address().port;
|
|
100
|
+
|
|
101
|
+
console.log(`โณ NTM proxy running on http://localhost:${port}`);
|
|
102
|
+
console.log(`โ๏ธ Mode: ${allowFallback ? "fallback enabled" : "strict"}${allowPrerelease ? ", prerelease enabled" : ""}`);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
port,
|
|
106
|
+
close: () => server.close()
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/reset.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function resetProject() {
|
|
5
|
+
const dir = path.join(process.cwd(), ".npm-time-machine");
|
|
6
|
+
|
|
7
|
+
if (fs.existsSync(dir)) {
|
|
8
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
9
|
+
console.log("โ Removed ntm configuration");
|
|
10
|
+
} else {
|
|
11
|
+
console.log("Nothing to reset");
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import fetch from "node-fetch";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
|
|
5
|
+
export function extractPackageName(packagePath) {
|
|
6
|
+
if (!packagePath || packagePath === "") {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const marker = "node_modules/";
|
|
11
|
+
const markerIndex = packagePath.lastIndexOf(marker);
|
|
12
|
+
|
|
13
|
+
if (markerIndex === -1) {
|
|
14
|
+
return packagePath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return packagePath.slice(markerIndex + marker.length) || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectFromPackageEntries(packages) {
|
|
21
|
+
const collected = [];
|
|
22
|
+
|
|
23
|
+
for (const [packagePath, info] of Object.entries(packages)) {
|
|
24
|
+
if (!info?.version || !semver.valid(info.version)) continue;
|
|
25
|
+
|
|
26
|
+
const packageName = extractPackageName(packagePath);
|
|
27
|
+
if (!packageName) continue;
|
|
28
|
+
|
|
29
|
+
collected.push({ name: packageName, version: info.version });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return collected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectFromDependencyTree(dependencies, collected = []) {
|
|
36
|
+
if (!dependencies) {
|
|
37
|
+
return collected;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const [name, info] of Object.entries(dependencies)) {
|
|
41
|
+
if (info?.version && semver.valid(info.version)) {
|
|
42
|
+
collected.push({ name, version: info.version });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
collectFromDependencyTree(info?.dependencies, collected);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return collected;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectLockfilePackages(lock) {
|
|
52
|
+
if (lock.packages && typeof lock.packages === "object") {
|
|
53
|
+
return collectFromPackageEntries(lock.packages);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (lock.dependencies && typeof lock.dependencies === "object") {
|
|
57
|
+
return collectFromDependencyTree(lock.dependencies);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function verifyProject(date) {
|
|
64
|
+
if (!fs.existsSync("package-lock.json")) {
|
|
65
|
+
throw new Error("No package-lock.json found");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf-8"));
|
|
69
|
+
const packages = collectLockfilePackages(lock);
|
|
70
|
+
|
|
71
|
+
let issues = 0;
|
|
72
|
+
const metadataCache = new Map();
|
|
73
|
+
const lookupFailures = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
|
|
76
|
+
for (const pkg of packages) {
|
|
77
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
78
|
+
if (seen.has(key)) continue;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
|
|
81
|
+
let data = metadataCache.get(pkg.name);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (!data) {
|
|
85
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg.name}`);
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`Registry responded with ${res.status}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
data = await res.json();
|
|
91
|
+
metadataCache.set(pkg.name, data);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const publishTime = data.time?.[pkg.version];
|
|
95
|
+
|
|
96
|
+
if (!publishTime) {
|
|
97
|
+
lookupFailures.push(`${pkg.name}@${pkg.version}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (new Date(publishTime) > date) {
|
|
102
|
+
console.log(`โ ${pkg.name}@${pkg.version} โ ${publishTime}`);
|
|
103
|
+
issues++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
} catch (error) {
|
|
107
|
+
lookupFailures.push(`${pkg.name}@${pkg.version} (${error.message})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (lookupFailures.length > 0) {
|
|
112
|
+
const preview = lookupFailures.slice(0, 5).join(", ");
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Failed to verify ${lookupFailures.length} package(s): ${preview}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (issues === 0) {
|
|
119
|
+
console.log("โ All dependencies are within the selected date");
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`โ Found ${issues} issues`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import semver from "semver";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Filters package versions by a target date
|
|
5
|
+
* @param {Object} packageData - NPM registry response with versions and time metadata
|
|
6
|
+
* @param {Date} targetDate - Cutoff date for version filtering
|
|
7
|
+
* @param {Object} options - Filter options
|
|
8
|
+
* @param {boolean} options.allowFallback - If true, fallback to oldest version when none match
|
|
9
|
+
* @param {boolean} options.allowPrerelease - If true, include pre-release versions
|
|
10
|
+
* @returns {Object} Filtered versions object or null if no versions match
|
|
11
|
+
*/
|
|
12
|
+
export function filterVersionsByDate(packageData, targetDate, options = {}) {
|
|
13
|
+
const { allowFallback = false, allowPrerelease = false } = options;
|
|
14
|
+
const { versions, time } = packageData;
|
|
15
|
+
|
|
16
|
+
if (!versions || !time) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const filtered = {};
|
|
21
|
+
|
|
22
|
+
// Filter versions by publish date
|
|
23
|
+
for (const [version, publishTime] of Object.entries(time)) {
|
|
24
|
+
// Skip metadata entries
|
|
25
|
+
if (["created", "modified"].includes(version)) continue;
|
|
26
|
+
|
|
27
|
+
// Check if version exists and meets criteria
|
|
28
|
+
if (
|
|
29
|
+
new Date(publishTime) <= targetDate &&
|
|
30
|
+
versions[version] &&
|
|
31
|
+
(allowPrerelease || !semver.prerelease(version))
|
|
32
|
+
) {
|
|
33
|
+
filtered[version] = versions[version];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Handle no versions before date
|
|
38
|
+
if (Object.keys(filtered).length === 0) {
|
|
39
|
+
if (allowFallback) {
|
|
40
|
+
const allVersions = Object.keys(versions || {});
|
|
41
|
+
const oldest = allVersions.sort(semver.compare)[0];
|
|
42
|
+
|
|
43
|
+
if (oldest) {
|
|
44
|
+
return {
|
|
45
|
+
versions: { [oldest]: versions[oldest] },
|
|
46
|
+
fallback: true,
|
|
47
|
+
fallbackVersion: oldest
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { versions: filtered, fallback: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Updates dist-tags in package metadata based on filtered versions
|
|
60
|
+
* @param {Object} packageData - NPM registry response
|
|
61
|
+
* @param {Object} filteredResult - Result from filterVersionsByDate
|
|
62
|
+
* @returns {Object} Updated package data
|
|
63
|
+
*/
|
|
64
|
+
export function updateDistTags(packageData, filteredResult) {
|
|
65
|
+
if (!filteredResult || !filteredResult.versions) {
|
|
66
|
+
return packageData;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const versions = Object.keys(filteredResult.versions);
|
|
70
|
+
if (versions.length > 0) {
|
|
71
|
+
const latest = versions.sort(semver.rcompare)[0];
|
|
72
|
+
packageData["dist-tags"] = packageData["dist-tags"] || {};
|
|
73
|
+
packageData["dist-tags"].latest = latest;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return packageData;
|
|
77
|
+
}
|