Skip to content

Commit db2e772

Browse files
feat: hd install
1 parent da03b40 commit db2e772

23 files changed

Lines changed: 3297 additions & 11 deletions

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ USAGE
9797
* [`hd auth logout`](#hd-auth-logout)
9898
* [`hd auth provision-ci-token`](#hd-auth-provision-ci-token)
9999
* [`hd help [COMMAND]`](#hd-help-command)
100+
* [`hd install`](#hd-install)
100101
* [`hd report committers`](#hd-report-committers)
101102
* [`hd scan eol`](#hd-scan-eol)
102103
* [`hd tracker init`](#hd-tracker-init)
@@ -165,6 +166,20 @@ DESCRIPTION
165166

166167
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.44/src/commands/help.ts)_
167168

169+
### `hd install`
170+
171+
Install dependencies through the HeroDevs NES npm proxy
172+
173+
```
174+
USAGE
175+
$ hd install
176+
177+
DESCRIPTION
178+
Install dependencies through the HeroDevs NES npm proxy
179+
```
180+
181+
_See code: [src/commands/install.ts](https://github.com/herodevs/cli/blob/v2.0.6/src/commands/install.ts)_
182+
168183
### `hd report committers`
169184

170185
Generate report of committers to a git repository
@@ -177,10 +192,10 @@ USAGE
177192
FLAGS
178193
-c, --csv Output in CSV format
179194
-d, --directory=<value> Directory to search
180-
-e, --afterDate=<value> [default: 2025-04-23] Start date (format: yyyy-MM-dd)
195+
-e, --afterDate=<value> [default: 2025-05-27] Start date (format: yyyy-MM-dd)
181196
-m, --months=<value> [default: 12] The number of months of git history to review. Cannot be used along beforeDate
182197
and afterDate
183-
-s, --beforeDate=<value> [default: 2026-04-23] End date (format: yyyy-MM-dd)
198+
-s, --beforeDate=<value> [default: 2026-05-27] End date (format: yyyy-MM-dd)
184199
-s, --save Save the committers report as herodevs.committers.<output>
185200
-x, --exclude=<value>... Path Exclusions (eg -x="./src/bin" -x="./dist")
186201
--json Output to JSON format

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
33
"assist": { "actions": { "source": { "organizeImports": "on" } } },
44
"linter": {
55
"enabled": true,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { equal, match } from 'node:assert/strict';
2+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
4+
import { tmpdir } from 'node:os';
5+
import path from 'node:path';
6+
import { afterEach, beforeEach, describe, it } from 'node:test';
7+
import { gzipSync } from 'node:zlib';
8+
import { runCommand } from '@oclif/test';
9+
10+
const fixturesDir = path.resolve(import.meta.dirname, '../../fixtures/install');
11+
const projectFixtureDir = path.join(fixturesDir, 'simple-project');
12+
const packageFixtureDir = path.join(fixturesDir, 'hd-demo-dep');
13+
14+
describe('install e2e', () => {
15+
const originalCwd = process.cwd();
16+
const originalCatalogUrl = process.env.HD_INSTALL_CATALOG_URL;
17+
const originalRegistryOverride = process.env.HD_INSTALL_NPM_REGISTRY_URL;
18+
const originalNpmCache = process.env.NPM_CONFIG_CACHE;
19+
let tempDir: string;
20+
let projectDir: string;
21+
let registry: MockRegistry | undefined;
22+
23+
beforeEach(async () => {
24+
tempDir = mkdtempSync(path.join(tmpdir(), 'hd-install-e2e-'));
25+
projectDir = path.join(tempDir, 'project');
26+
cpSync(projectFixtureDir, projectDir, { recursive: true });
27+
28+
const tarballPath = createFixtureTarball(tempDir);
29+
registry = await startMockRegistry(tarballPath);
30+
process.env.HD_INSTALL_CATALOG_URL = `${registry.url}/catalog`;
31+
process.env.HD_INSTALL_NPM_REGISTRY_URL = registry.url;
32+
process.env.NPM_CONFIG_CACHE = path.join(tempDir, '.npm-cache');
33+
process.chdir(projectDir);
34+
});
35+
36+
afterEach(() => {
37+
registry?.close();
38+
process.chdir(originalCwd);
39+
restoreEnv('HD_INSTALL_CATALOG_URL', originalCatalogUrl);
40+
restoreEnv('HD_INSTALL_NPM_REGISTRY_URL', originalRegistryOverride);
41+
restoreEnv('NPM_CONFIG_CACHE', originalNpmCache);
42+
rmSync(tempDir, { recursive: true, force: true });
43+
});
44+
45+
it('runs npm install through the local proxy against a real fixture project', async () => {
46+
if (!registry) {
47+
throw new Error('Mock registry was not started');
48+
}
49+
50+
const output = await runCommand('install');
51+
equal(output.error, undefined);
52+
53+
const stdout = output.stdout;
54+
match(stdout, /Install completed\./);
55+
56+
const installedPackagePath = path.join(projectDir, 'node_modules', 'hd-demo-dep', 'index.js');
57+
equal(existsSync(installedPackagePath), true);
58+
equal(readFileSync(installedPackagePath, 'utf8'), "module.exports = 'installed through hd install';\n");
59+
60+
const lockfile = JSON.parse(readFileSync(path.join(projectDir, 'package-lock.json'), 'utf8'));
61+
equal(lockfile.packages['node_modules/hd-demo-dep'].version, '1.0.0');
62+
match(lockfile.packages['node_modules/hd-demo-dep'].resolved, /\/hd-demo-dep-1\.0\.0-hd-demo-dep-1\.0\.1\.tgz$/);
63+
});
64+
});
65+
66+
function restoreEnv(name: string, value: string | undefined): void {
67+
if (value === undefined) {
68+
delete process.env[name];
69+
return;
70+
}
71+
process.env[name] = value;
72+
}
73+
74+
function createFixtureTarball(destination: string): string {
75+
const entries = [
76+
createTarEntry('package/package.json', readFileSync(path.join(packageFixtureDir, 'package.json'))),
77+
createTarEntry('package/index.js', readFileSync(path.join(packageFixtureDir, 'index.js'))),
78+
];
79+
const tarballPath = path.join(destination, 'hd-demo-dep-1.0.0.tgz');
80+
writeFileSync(tarballPath, gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)])));
81+
return tarballPath;
82+
}
83+
84+
function createTarEntry(name: string, content: Buffer): Buffer {
85+
const header = Buffer.alloc(512);
86+
writeTarString(header, name, 0, 100);
87+
writeTarOctal(header, 0o644, 100, 8);
88+
writeTarOctal(header, 0, 108, 8);
89+
writeTarOctal(header, 0, 116, 8);
90+
writeTarOctal(header, content.length, 124, 12);
91+
writeTarOctal(header, 0, 136, 12);
92+
header.fill(' ', 148, 156);
93+
header[156] = '0'.charCodeAt(0);
94+
writeTarString(header, 'ustar', 257, 6);
95+
writeTarString(header, '00', 263, 2);
96+
97+
let checksum = 0;
98+
for (const byte of header) {
99+
checksum += byte;
100+
}
101+
writeTarOctal(header, checksum, 148, 8);
102+
103+
return Buffer.concat([header, content, Buffer.alloc(padToTarBlock(content.length))]);
104+
}
105+
106+
function writeTarString(header: Buffer, value: string, offset: number, length: number): void {
107+
header.write(value, offset, Math.min(Buffer.byteLength(value), length), 'utf8');
108+
}
109+
110+
function writeTarOctal(header: Buffer, value: number, offset: number, length: number): void {
111+
const encoded = value.toString(8).padStart(length - 1, '0');
112+
header.write(`${encoded}\0`, offset, length, 'ascii');
113+
}
114+
115+
function padToTarBlock(size: number): number {
116+
const remainder = size % 512;
117+
return remainder === 0 ? 0 : 512 - remainder;
118+
}
119+
120+
interface MockRegistry {
121+
url: string;
122+
close: () => void;
123+
}
124+
125+
async function startMockRegistry(tarballPath: string): Promise<MockRegistry> {
126+
let registryUrl = '';
127+
const server = createServer((req, res) => {
128+
handleRegistryRequest(req, res, registryUrl, tarballPath);
129+
});
130+
131+
await new Promise<void>((resolve, reject) => {
132+
server.once('error', reject);
133+
server.listen(0, '127.0.0.1', resolve);
134+
});
135+
136+
const address = server.address();
137+
if (!address || typeof address === 'string') {
138+
throw new Error('Mock registry did not bind to a TCP port');
139+
}
140+
141+
registryUrl = `http://127.0.0.1:${address.port}`;
142+
143+
return {
144+
url: registryUrl,
145+
close: () => {
146+
server.closeAllConnections();
147+
server.close();
148+
},
149+
};
150+
}
151+
152+
function handleRegistryRequest(
153+
req: IncomingMessage,
154+
res: ServerResponse,
155+
registryUrl: string,
156+
tarballPath: string,
157+
): void {
158+
const url = new URL(req.url ?? '/', registryUrl);
159+
const decodedPathname = decodeURIComponent(url.pathname);
160+
161+
if (
162+
req.method === 'GET' &&
163+
(decodedPathname === '/hd-demo-dep' || decodedPathname === '/@neverendingsupport/hd-demo-dep')
164+
) {
165+
const isNesPackage = decodedPathname === '/@neverendingsupport/hd-demo-dep';
166+
const packageName = isNesPackage ? '@neverendingsupport/hd-demo-dep' : 'hd-demo-dep';
167+
const packageVersion = isNesPackage ? '1.0.0-hd-demo-dep-1.0.1' : '1.0.0';
168+
const tarballUrl = isNesPackage
169+
? `${registryUrl}/%40neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz`
170+
: `${registryUrl}/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz`;
171+
172+
sendJson(res, {
173+
name: packageName,
174+
'dist-tags': { latest: packageVersion },
175+
versions: {
176+
[packageVersion]: {
177+
name: packageName,
178+
version: packageVersion,
179+
main: 'index.js',
180+
dist: {
181+
tarball: tarballUrl,
182+
},
183+
},
184+
},
185+
});
186+
return;
187+
}
188+
189+
if (req.method === 'GET' && decodedPathname === '/catalog') {
190+
sendJson(res, {
191+
results: [
192+
{
193+
component: 'pkg:npm/hd-demo-dep',
194+
versions: [
195+
{
196+
version: '1.0.0',
197+
nes: {
198+
latest: '1.0.0-hd-demo-dep-1.0.1',
199+
purl: 'pkg:npm/%40neverendingsupport/hd-demo-dep',
200+
},
201+
},
202+
],
203+
},
204+
],
205+
totalPages: 1,
206+
});
207+
return;
208+
}
209+
210+
if (
211+
req.method === 'GET' &&
212+
(decodedPathname === '/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz' ||
213+
decodedPathname === '/@neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz')
214+
) {
215+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
216+
res.end(readFileSync(tarballPath));
217+
return;
218+
}
219+
220+
res.writeHead(404, { 'content-type': 'application/json' });
221+
res.end(JSON.stringify({ error: 'not found' }));
222+
}
223+
224+
function sendJson(res: ServerResponse, body: unknown): void {
225+
res.writeHead(200, { 'content-type': 'application/json' });
226+
res.end(JSON.stringify(body));
227+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = 'installed through hd install';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "hd-demo-dep",
3+
"version": "1.0.0",
4+
"main": "index.js"
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "hd-install-simple-project",
3+
"version": "1.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"hd-demo-dep": "1.0.0"
7+
}
8+
}

e2e/setup/mock-auth-hooks.mjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* ESM loader hooks that replace auth.svc.ts with a mock during E2E tests.
3-
* This avoids writing encrypted token files during E2E tests.
2+
* ESM loader hooks that replace auth services with mocks during E2E tests.
3+
* This avoids writing encrypted token files or calling HeroDevs auth APIs during E2E tests.
44
*/
55
export async function load(url, context, nextLoad) {
66
if (url.endsWith('/service/auth.svc.ts') || url.endsWith('/service/auth.svc.js')) {
@@ -33,5 +33,15 @@ export async function load(url, context, nextLoad) {
3333
};
3434
}
3535

36+
if (url.endsWith('/service/install/registry-auth.svc.ts') || url.endsWith('/service/install/registry-auth.svc.js')) {
37+
return {
38+
format: 'module',
39+
shortCircuit: true,
40+
source: `
41+
export function getNesRegistryAuthToken() { return Promise.resolve('test-registry-token'); }
42+
`,
43+
};
44+
}
45+
3646
return nextLoad(url, context);
3747
}

0 commit comments

Comments
 (0)