Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
fail-fast: false
max-parallel: 3
matrix:
node: [20, 22, 24]
node: [22, 24]
platform: [ubuntu-latest, macos-latest, windows-latest]
name: "${{matrix.platform}} w/ Node.js ${{matrix.node}}.x"
runs-on: ${{matrix.platform}}
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ USAGE
* [`hd auth logout`](#hd-auth-logout)
* [`hd auth provision-ci-token`](#hd-auth-provision-ci-token)
* [`hd help [COMMAND]`](#hd-help-command)
* [`hd install`](#hd-install)
* [`hd report committers`](#hd-report-committers)
* [`hd scan eol`](#hd-scan-eol)
* [`hd tracker init`](#hd-tracker-init)
Expand Down Expand Up @@ -165,6 +166,20 @@ DESCRIPTION

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

### `hd install`

Install dependencies through the HeroDevs NES npm proxy

```
USAGE
$ hd install

DESCRIPTION
Install dependencies through the HeroDevs NES npm proxy
```

_See code: [src/commands/install.ts](https://github.com/herodevs/cli/blob/v2.0.6/src/commands/install.ts)_

### `hd report committers`

Generate report of committers to a git repository
Expand All @@ -177,10 +192,10 @@ USAGE
FLAGS
-c, --csv Output in CSV format
-d, --directory=<value> Directory to search
-e, --afterDate=<value> [default: 2025-04-23] Start date (format: yyyy-MM-dd)
-e, --afterDate=<value> [default: 2025-05-27] Start date (format: yyyy-MM-dd)
-m, --months=<value> [default: 12] The number of months of git history to review. Cannot be used along beforeDate
and afterDate
-s, --beforeDate=<value> [default: 2026-04-23] End date (format: yyyy-MM-dd)
-s, --beforeDate=<value> [default: 2026-05-27] End date (format: yyyy-MM-dd)
-s, --save Save the committers report as herodevs.committers.<output>
-x, --exclude=<value>... Path Exclusions (eg -x="./src/bin" -x="./dist")
--json Output to JSON format
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
Expand Down
227 changes: 227 additions & 0 deletions e2e/commands/install/install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { equal, match } from 'node:assert/strict';
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { gzipSync } from 'node:zlib';
import { runCommand } from '@oclif/test';

const fixturesDir = path.resolve(import.meta.dirname, '../../fixtures/install');
const projectFixtureDir = path.join(fixturesDir, 'simple-project');
const packageFixtureDir = path.join(fixturesDir, 'hd-demo-dep');

describe('install e2e', () => {
const originalCwd = process.cwd();
const originalCatalogUrl = process.env.HD_INSTALL_CATALOG_URL;
const originalRegistryOverride = process.env.HD_INSTALL_NPM_REGISTRY_URL;
const originalNpmCache = process.env.NPM_CONFIG_CACHE;
let tempDir: string;
let projectDir: string;
let registry: MockRegistry | undefined;

beforeEach(async () => {
tempDir = mkdtempSync(path.join(tmpdir(), 'hd-install-e2e-'));
projectDir = path.join(tempDir, 'project');
cpSync(projectFixtureDir, projectDir, { recursive: true });

const tarballPath = createFixtureTarball(tempDir);
registry = await startMockRegistry(tarballPath);
process.env.HD_INSTALL_CATALOG_URL = `${registry.url}/catalog`;
process.env.HD_INSTALL_NPM_REGISTRY_URL = registry.url;
process.env.NPM_CONFIG_CACHE = path.join(tempDir, '.npm-cache');
process.chdir(projectDir);
});

afterEach(() => {
registry?.close();
process.chdir(originalCwd);
restoreEnv('HD_INSTALL_CATALOG_URL', originalCatalogUrl);
restoreEnv('HD_INSTALL_NPM_REGISTRY_URL', originalRegistryOverride);
restoreEnv('NPM_CONFIG_CACHE', originalNpmCache);
rmSync(tempDir, { recursive: true, force: true });
});

it('runs npm install through the local proxy against a real fixture project', async () => {
if (!registry) {
throw new Error('Mock registry was not started');
}

const output = await runCommand('install');
equal(output.error, undefined);

const stdout = output.stdout;
match(stdout, /Install completed\./);

const installedPackagePath = path.join(projectDir, 'node_modules', 'hd-demo-dep', 'index.js');
equal(existsSync(installedPackagePath), true);
equal(readFileSync(installedPackagePath, 'utf8'), "module.exports = 'installed through hd install';\n");

const lockfile = JSON.parse(readFileSync(path.join(projectDir, 'package-lock.json'), 'utf8'));
equal(lockfile.packages['node_modules/hd-demo-dep'].version, '1.0.0');
match(lockfile.packages['node_modules/hd-demo-dep'].resolved, /\/hd-demo-dep-1\.0\.0-hd-demo-dep-1\.0\.1\.tgz$/);
});
});

function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}

function createFixtureTarball(destination: string): string {
const entries = [
createTarEntry('package/package.json', readFileSync(path.join(packageFixtureDir, 'package.json'))),
createTarEntry('package/index.js', readFileSync(path.join(packageFixtureDir, 'index.js'))),
];
const tarballPath = path.join(destination, 'hd-demo-dep-1.0.0.tgz');
writeFileSync(tarballPath, gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)])));
return tarballPath;
}

function createTarEntry(name: string, content: Buffer): Buffer {
const header = Buffer.alloc(512);
writeTarString(header, name, 0, 100);
writeTarOctal(header, 0o644, 100, 8);
writeTarOctal(header, 0, 108, 8);
writeTarOctal(header, 0, 116, 8);
writeTarOctal(header, content.length, 124, 12);
writeTarOctal(header, 0, 136, 12);
header.fill(' ', 148, 156);
header[156] = '0'.charCodeAt(0);
writeTarString(header, 'ustar', 257, 6);
writeTarString(header, '00', 263, 2);

let checksum = 0;
for (const byte of header) {
checksum += byte;
}
writeTarOctal(header, checksum, 148, 8);

return Buffer.concat([header, content, Buffer.alloc(padToTarBlock(content.length))]);
}

function writeTarString(header: Buffer, value: string, offset: number, length: number): void {
header.write(value, offset, Math.min(Buffer.byteLength(value), length), 'utf8');
}

function writeTarOctal(header: Buffer, value: number, offset: number, length: number): void {
const encoded = value.toString(8).padStart(length - 1, '0');
header.write(`${encoded}\0`, offset, length, 'ascii');
}

function padToTarBlock(size: number): number {
const remainder = size % 512;
return remainder === 0 ? 0 : 512 - remainder;
}

interface MockRegistry {
url: string;
close: () => void;
}

async function startMockRegistry(tarballPath: string): Promise<MockRegistry> {
let registryUrl = '';
const server = createServer((req, res) => {
handleRegistryRequest(req, res, registryUrl, tarballPath);
});

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});

const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Mock registry did not bind to a TCP port');
}

registryUrl = `http://127.0.0.1:${address.port}`;

return {
url: registryUrl,
close: () => {
server.closeAllConnections();
server.close();
},
};
}

function handleRegistryRequest(
req: IncomingMessage,
res: ServerResponse,
registryUrl: string,
tarballPath: string,
): void {
const url = new URL(req.url ?? '/', registryUrl);
const decodedPathname = decodeURIComponent(url.pathname);

if (
req.method === 'GET' &&
(decodedPathname === '/hd-demo-dep' || decodedPathname === '/@neverendingsupport/hd-demo-dep')
) {
const isNesPackage = decodedPathname === '/@neverendingsupport/hd-demo-dep';
const packageName = isNesPackage ? '@neverendingsupport/hd-demo-dep' : 'hd-demo-dep';
const packageVersion = isNesPackage ? '1.0.0-hd-demo-dep-1.0.1' : '1.0.0';
const tarballUrl = isNesPackage
? `${registryUrl}/%40neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz`
: `${registryUrl}/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz`;

sendJson(res, {
name: packageName,
'dist-tags': { latest: packageVersion },
versions: {
[packageVersion]: {
name: packageName,
version: packageVersion,
main: 'index.js',
dist: {
tarball: tarballUrl,
},
},
},
});
return;
}

if (req.method === 'GET' && decodedPathname === '/catalog') {
sendJson(res, {
results: [
{
component: 'pkg:npm/hd-demo-dep',
versions: [
{
version: '1.0.0',
nes: {
latest: '1.0.0-hd-demo-dep-1.0.1',
purl: 'pkg:npm/%40neverendingsupport/hd-demo-dep',
},
},
],
},
],
totalPages: 1,
});
return;
}

if (
req.method === 'GET' &&
(decodedPathname === '/hd-demo-dep/-/hd-demo-dep-1.0.0.tgz' ||
decodedPathname === '/@neverendingsupport/hd-demo-dep/-/hd-demo-dep-1.0.0-hd-demo-dep-1.0.1.tgz')
) {
res.writeHead(200, { 'content-type': 'application/octet-stream' });
res.end(readFileSync(tarballPath));
return;
}

res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
}

function sendJson(res: ServerResponse, body: unknown): void {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
1 change: 1 addition & 0 deletions e2e/fixtures/install/hd-demo-dep/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = 'installed through hd install';
5 changes: 5 additions & 0 deletions e2e/fixtures/install/hd-demo-dep/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "hd-demo-dep",
"version": "1.0.0",
"main": "index.js"
}
8 changes: 8 additions & 0 deletions e2e/fixtures/install/simple-project/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "hd-install-simple-project",
"version": "1.0.0",
"private": true,
"dependencies": {
"hd-demo-dep": "1.0.0"
}
}
14 changes: 12 additions & 2 deletions e2e/setup/mock-auth-hooks.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* ESM loader hooks that replace auth.svc.ts with a mock during E2E tests.
* This avoids writing encrypted token files during E2E tests.
* ESM loader hooks that replace auth services with mocks during E2E tests.
* This avoids writing encrypted token files or calling HeroDevs auth APIs during E2E tests.
*/
export async function load(url, context, nextLoad) {
if (url.endsWith('/service/auth.svc.ts') || url.endsWith('/service/auth.svc.js')) {
Expand Down Expand Up @@ -33,5 +33,15 @@ export async function load(url, context, nextLoad) {
};
}

if (url.endsWith('/service/install/registry-auth.svc.ts') || url.endsWith('/service/install/registry-auth.svc.js')) {
return {
format: 'module',
shortCircuit: true,
source: `
export function getNesRegistryAuthToken() { return Promise.resolve('test-registry-token'); }
`,
};
}

return nextLoad(url, context);
}
Loading
Loading