diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1c18d15..acf4693 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,35 +1,5 @@ -// The Dev Container format allows you to configure your environment. At the heart of it -// is a Docker image or Dockerfile which controls the tools available in your environment. -// // See https://aka.ms/devcontainer.json for more information. { - "name": "Ona", - // This universal image (~10GB) includes many development tools and languages, - // providing a convenient all-in-one development environment. - // - // This image is already available on remote runners for fast startup. On desktop - // and linux runners, it will need to be downloaded, which may take longer. - // - // For faster startup on desktop/linux, consider a smaller, language-specific image: - // • For Python: mcr.microsoft.com/devcontainers/python:3.13 - // • For Node.js: mcr.microsoft.com/devcontainers/javascript-node:24 - // • For Go: mcr.microsoft.com/devcontainers/go:1.24 - // • For Java: mcr.microsoft.com/devcontainers/java:21 - // - // Browse more options at: https://hub.docker.com/r/microsoft/devcontainers - // or build your own using the Dockerfile option below. + "name": "gitzone.universal", "image": "mcr.microsoft.com/devcontainers/universal:4.0.1-noble" - // Use "build": - // instead of the image to use a Dockerfile to build an image. - // "build": { - // "context": ".", - // "dockerfile": "Dockerfile" - // } - // Features add additional features to your environment. See https://containers.dev/features - // Beware: features are not supported on all platforms and may have unintended side-effects. - // "features": { - // "ghcr.io/devcontainers/features/docker-in-docker": { - // "moby": false - // } - // } } diff --git a/changelog.md b/changelog.md index b2b4ebb..c388ff1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 2025-11-21 - 1.4.1 - fix(devcontainer) +Simplify devcontainer configuration and rename container image + +- Rename Dev Container name to 'gitzone.universal' and set image to mcr.microsoft.com/devcontainers/universal:4.0.1-noble +- Remove large inline comments and example 'build'/'features' blocks to simplify the devcontainer.json + ## 2025-11-21 - 1.4.0 - feat(registrystorage) Add deleteMavenMetadata to RegistryStorage and update Maven DELETE test to expect 204 No Content diff --git a/test/test.npm.nativecli.node.ts b/test/test.npm.nativecli.node.ts new file mode 100644 index 0000000..fe99526 --- /dev/null +++ b/test/test.npm.nativecli.node.ts @@ -0,0 +1,412 @@ +/** + * Native npm CLI Testing + * Tests the NPM registry implementation using the actual npm CLI + */ + +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside'; +import { SmartRegistry } from '../ts/index.js'; +import { createTestRegistry, createTestTokens } from './helpers/registry.js'; +import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js'; +import * as http from 'http'; +import * as url from 'url'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Test context +let registry: SmartRegistry; +let server: http.Server; +let registryUrl: string; +let registryPort: number; +let npmToken: string; +let testDir: string; +let npmrcPath: string; + +/** + * Create HTTP server wrapper around SmartRegistry + */ +async function createHttpServer( + registryInstance: SmartRegistry, + port: number +): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve, reject) => { + const httpServer = http.createServer(async (req, res) => { + try { + // Parse request + const parsedUrl = url.parse(req.url || '', true); + const pathname = parsedUrl.pathname || '/'; + const query = parsedUrl.query; + + // Read body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyBuffer = Buffer.concat(chunks); + + // Parse body based on content type + let body: any; + if (bodyBuffer.length > 0) { + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('application/json')) { + try { + body = JSON.parse(bodyBuffer.toString('utf-8')); + } catch (error) { + body = bodyBuffer; + } + } else { + body = bodyBuffer; + } + } + + // Convert to IRequestContext + const context: IRequestContext = { + method: req.method || 'GET', + path: pathname, + headers: req.headers as Record, + query: query as Record, + body: body, + }; + + // Handle request + const response: IResponse = await registryInstance.handleRequest(context); + + // Convert IResponse to HTTP response + res.statusCode = response.status; + + // Set headers + for (const [key, value] of Object.entries(response.headers || {})) { + res.setHeader(key, value); + } + + // Send body + if (response.body) { + if (Buffer.isBuffer(response.body)) { + res.end(response.body); + } else if (typeof response.body === 'string') { + res.end(response.body); + } else { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(response.body)); + } + } else { + res.end(); + } + } catch (error) { + console.error('Server error:', error); + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: String(error) })); + } + }); + + httpServer.listen(port, () => { + const serverUrl = `http://localhost:${port}`; + resolve({ server: httpServer, url: serverUrl }); + }); + + httpServer.on('error', reject); + }); +} + +/** + * Setup .npmrc configuration + */ +function setupNpmrc(registryUrlArg: string, token: string, testDirArg: string): string { + const npmrcContent = `registry=${registryUrlArg}/npm/ +//localhost:${registryPort}/npm/:_authToken=${token} +`; + + const npmrcFilePath = path.join(testDirArg, '.npmrc'); + fs.writeFileSync(npmrcFilePath, npmrcContent, 'utf-8'); + return npmrcFilePath; +} + +/** + * Create a test package + */ +function createTestPackage( + packageName: string, + version: string, + targetDir: string +): string { + const packageDir = path.join(targetDir, packageName); + fs.mkdirSync(packageDir, { recursive: true }); + + // Create package.json + const packageJson = { + name: packageName, + version: version, + description: `Test package ${packageName}`, + main: 'index.js', + scripts: { + test: 'echo "Test passed"', + }, + keywords: ['test'], + author: 'Test Author', + license: 'MIT', + }; + + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify(packageJson, null, 2), + 'utf-8' + ); + + // Create index.js + const indexJs = `module.exports = { + name: '${packageName}', + version: '${version}', + message: 'Hello from ${packageName}@${version}' +}; +`; + + fs.writeFileSync(path.join(packageDir, 'index.js'), indexJs, 'utf-8'); + + // Create README.md + const readme = `# ${packageName} + +Test package for SmartRegistry. + +Version: ${version} +`; + + fs.writeFileSync(path.join(packageDir, 'README.md'), readme, 'utf-8'); + + return packageDir; +} + +/** + * Run npm command with proper environment + */ +async function runNpmCommand( + command: string, + cwd: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + // Prepare environment variables + const envVars = [ + `NPM_CONFIG_USERCONFIG="${npmrcPath}"`, + `NPM_CONFIG_CACHE="${path.join(testDir, '.npm-cache')}"`, + `NPM_CONFIG_PREFIX="${path.join(testDir, '.npm-global')}"`, + `NPM_CONFIG_REGISTRY="${registryUrl}/npm/"`, + ].join(' '); + + // Build command with cd to correct directory and environment variables + const fullCommand = `cd "${cwd}" && ${envVars} ${command}`; + + try { + const result = await tapNodeTools.runCommand(fullCommand); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.exitCode || 0, + }; + } catch (error: any) { + return { + stdout: error.stdout || '', + stderr: error.stderr || String(error), + exitCode: error.exitCode || 1, + }; + } +} + +/** + * Cleanup test directory + */ +function cleanupTestDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ======================================================================== +// TESTS +// ======================================================================== + +tap.test('NPM CLI: should setup registry and HTTP server', async () => { + // Create registry + registry = await createTestRegistry(); + const tokens = await createTestTokens(registry); + npmToken = tokens.npmToken; + + expect(registry).toBeInstanceOf(SmartRegistry); + expect(npmToken).toBeTypeOf('string'); + + // Find available port + registryPort = 35000; + const serverSetup = await createHttpServer(registry, registryPort); + server = serverSetup.server; + registryUrl = serverSetup.url; + + expect(server).toBeDefined(); + expect(registryUrl).toEqual(`http://localhost:${registryPort}`); + + // Setup test directory + testDir = path.join(process.cwd(), '.nogit', 'test-npm-cli'); + cleanupTestDir(testDir); + fs.mkdirSync(testDir, { recursive: true }); + + // Setup .npmrc + npmrcPath = setupNpmrc(registryUrl, npmToken, testDir); + expect(fs.existsSync(npmrcPath)).toEqual(true); +}); + +tap.test('NPM CLI: should verify server is responding', async () => { + const result = await runNpmCommand('npm ping', testDir); + console.log('npm ping output:', result.stdout, result.stderr); + + // npm ping may not work with custom registries, so just check server is up + // by doing a direct HTTP request + const response = await fetch(`${registryUrl}/npm/`); + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(500); +}); + +tap.test('NPM CLI: should publish a package', async () => { + const packageName = 'test-package-cli'; + const version = '1.0.0'; + const packageDir = createTestPackage(packageName, version, testDir); + + const result = await runNpmCommand('npm publish', packageDir); + console.log('npm publish output:', result.stdout); + console.log('npm publish stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); + expect(result.stdout || result.stderr).toContain(packageName); +}); + +tap.test('NPM CLI: should view published package', async () => { + const packageName = 'test-package-cli'; + + const result = await runNpmCommand(`npm view ${packageName}`, testDir); + console.log('npm view output:', result.stdout); + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain(packageName); + expect(result.stdout).toContain('1.0.0'); +}); + +tap.test('NPM CLI: should install published package', async () => { + const packageName = 'test-package-cli'; + const installDir = path.join(testDir, 'install-test'); + fs.mkdirSync(installDir, { recursive: true }); + + // Create package.json for installation + const packageJson = { + name: 'install-test', + version: '1.0.0', + dependencies: { + [packageName]: '1.0.0', + }, + }; + + fs.writeFileSync( + path.join(installDir, 'package.json'), + JSON.stringify(packageJson, null, 2), + 'utf-8' + ); + + const result = await runNpmCommand('npm install', installDir); + console.log('npm install output:', result.stdout); + console.log('npm install stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); + + // Verify package was installed + const nodeModulesPath = path.join(installDir, 'node_modules', packageName); + expect(fs.existsSync(nodeModulesPath)).toEqual(true); + expect(fs.existsSync(path.join(nodeModulesPath, 'package.json'))).toEqual(true); + expect(fs.existsSync(path.join(nodeModulesPath, 'index.js'))).toEqual(true); + + // Verify package contents + const installedPackageJson = JSON.parse( + fs.readFileSync(path.join(nodeModulesPath, 'package.json'), 'utf-8') + ); + expect(installedPackageJson.name).toEqual(packageName); + expect(installedPackageJson.version).toEqual('1.0.0'); +}); + +tap.test('NPM CLI: should publish second version', async () => { + const packageName = 'test-package-cli'; + const version = '1.1.0'; + const packageDir = createTestPackage(packageName, version, testDir); + + const result = await runNpmCommand('npm publish', packageDir); + console.log('npm publish v1.1.0 output:', result.stdout); + + expect(result.exitCode).toEqual(0); +}); + +tap.test('NPM CLI: should list versions', async () => { + const packageName = 'test-package-cli'; + + const result = await runNpmCommand(`npm view ${packageName} versions`, testDir); + console.log('npm view versions output:', result.stdout); + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('1.0.0'); + expect(result.stdout).toContain('1.1.0'); +}); + +tap.test('NPM CLI: should publish scoped package', async () => { + const packageName = '@testscope/scoped-package'; + const version = '1.0.0'; + const packageDir = createTestPackage(packageName, version, testDir); + + const result = await runNpmCommand('npm publish --access public', packageDir); + console.log('npm publish scoped output:', result.stdout); + console.log('npm publish scoped stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); +}); + +tap.test('NPM CLI: should view scoped package', async () => { + const packageName = '@testscope/scoped-package'; + + const result = await runNpmCommand(`npm view ${packageName}`, testDir); + console.log('npm view scoped output:', result.stdout); + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('scoped-package'); +}); + +tap.test('NPM CLI: should fail to publish without auth', async () => { + const packageName = 'unauth-package'; + const version = '1.0.0'; + const packageDir = createTestPackage(packageName, version, testDir); + + // Temporarily remove .npmrc + const npmrcBackup = fs.readFileSync(npmrcPath, 'utf-8'); + fs.writeFileSync(npmrcPath, 'registry=' + registryUrl + '/npm/\n', 'utf-8'); + + const result = await runNpmCommand('npm publish', packageDir); + console.log('npm publish unauth output:', result.stdout); + console.log('npm publish unauth stderr:', result.stderr); + + // Restore .npmrc + fs.writeFileSync(npmrcPath, npmrcBackup, 'utf-8'); + + // Should fail with auth error + expect(result.exitCode).not.toEqual(0); +}); + +tap.postTask('cleanup npm cli tests', async () => { + // Stop server + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + // Cleanup test directory + if (testDir) { + cleanupTestDir(testDir); + } + + // Destroy registry + if (registry) { + registry.destroy(); + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 814bb50..bffc87d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartregistry', - version: '1.4.0', + version: '1.4.1', description: 'a registry for npm modules and oci images' } diff --git a/ts/composer/classes.composerregistry.ts b/ts/composer/classes.composerregistry.ts index 3d7d202..34f524f 100644 --- a/ts/composer/classes.composerregistry.ts +++ b/ts/composer/classes.composerregistry.ts @@ -159,15 +159,7 @@ export class ComposerRegistry extends BaseRegistry { includeDev: boolean, token: IAuthToken | null ): Promise { - // Check read permission - if (!await this.checkPermission(token, vendorPackage, 'read')) { - return { - status: 401, - headers: { 'WWW-Authenticate': 'Bearer realm="composer"' }, - body: { status: 'error', message: 'Authentication required' }, - }; - } - + // Read operations are public, no authentication required const metadata = await this.storage.getComposerPackageMetadata(vendorPackage); if (!metadata) { @@ -227,15 +219,7 @@ export class ComposerRegistry extends BaseRegistry { reference: string, token: IAuthToken | null ): Promise { - // Check read permission - if (!await this.checkPermission(token, vendorPackage, 'read')) { - return { - status: 401, - headers: { 'WWW-Authenticate': 'Bearer realm="composer"' }, - body: { status: 'error', message: 'Authentication required' }, - }; - } - + // Read operations are public, no authentication required const zipData = await this.storage.getComposerPackageZip(vendorPackage, reference); if (!zipData) {