diff --git a/docs/src/content/docs/targets/registry.md b/docs/src/content/docs/targets/registry.md index bb524152..96fe14e5 100644 --- a/docs/src/content/docs/targets/registry.md +++ b/docs/src/content/docs/targets/registry.md @@ -24,6 +24,10 @@ Avoid having multiple `registry` targets—it supports batching multiple apps an | `linkPrereleases` | Update for preview releases. Default: `false` | | `checksums` | List of checksum configs | | `onlyIfPresent` | Only run if matching file exists | +| `name` | Human-readable name (used when creating new packages) | +| `packageUrl` | Link to package registry page, e.g., npmjs.com (used when creating new packages) | +| `mainDocsUrl` | Link to main documentation (used when creating new packages) | +| `apiDocsUrl` | Link to API documentation (used when creating new packages) | ### Checksum Configuration @@ -52,3 +56,22 @@ targets: - **sdk**: Package uploaded to public registries (PyPI, NPM, etc.) - **app**: Standalone application with version files in the registry + +## Creating New Packages + +When you introduce a new package that doesn't yet exist in the release registry, Craft will automatically create the required directory structure and initial manifest. + +```yaml +targets: + - name: registry + sdks: + 'npm:@sentry/wasm': + name: 'Sentry WASM' + packageUrl: 'https://www.npmjs.com/package/@sentry/wasm' + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/' + urlTemplate: 'https://example.com/{{version}}/{{file}}' +``` + +## Manifest Metadata + +The `repo_url` field is always derived from your GitHub repository configuration. When specified, the metadata fields (`name`, `packageUrl`, `mainDocsUrl`, `apiDocsUrl`) are applied to every release, allowing you to update package metadata by changing your `.craft.yml` configuration. diff --git a/src/targets/__tests__/registry.test.ts b/src/targets/__tests__/registry.test.ts index 5212d285..c87365e1 100644 --- a/src/targets/__tests__/registry.test.ts +++ b/src/targets/__tests__/registry.test.ts @@ -1,4 +1,4 @@ -import { vi, type Mock, type MockInstance, type Mocked, type MockedFunction } from 'vitest'; +import { vi, type Mock, type MockedFunction } from 'vitest'; vi.mock('../../utils/githubApi.ts'); import { getGitHubClient } from '../../utils/githubApi'; import { RegistryConfig, RegistryTarget } from '../registry'; @@ -46,4 +46,106 @@ describe('getUpdatedManifest', () => { // check if property created_at exists expect(updatedManifest).toHaveProperty('created_at'); }); + + it('always sets repo_url from githubRepo config', async () => { + const registryConfig: RegistryConfig = { + type: RegistryPackageType.SDK, + canonicalName: 'example-package', + }; + const packageManifest = { + canonical: 'example-package', + repo_url: 'https://github.com/old/repo', + }; + + const updatedManifest = await target.getUpdatedManifest( + registryConfig, + packageManifest, + 'example-package', + '1.0.0', + 'abc123' + ); + + expect(updatedManifest.repo_url).toBe( + 'https://github.com/testSourceOwner/testSourceRepo' + ); + }); + + it('applies config metadata fields to manifest', async () => { + const registryConfig: RegistryConfig = { + type: RegistryPackageType.SDK, + canonicalName: 'example-package', + name: 'Example Package', + packageUrl: 'https://npmjs.com/package/example', + mainDocsUrl: 'https://docs.example.com', + apiDocsUrl: 'https://api.example.com/docs', + }; + const packageManifest = { + canonical: 'example-package', + }; + + const updatedManifest = await target.getUpdatedManifest( + registryConfig, + packageManifest, + 'example-package', + '1.0.0', + 'abc123' + ); + + expect(updatedManifest.name).toBe('Example Package'); + expect(updatedManifest.package_url).toBe('https://npmjs.com/package/example'); + expect(updatedManifest.main_docs_url).toBe('https://docs.example.com'); + expect(updatedManifest.api_docs_url).toBe('https://api.example.com/docs'); + }); + + it('config metadata fields override existing manifest values', async () => { + const registryConfig: RegistryConfig = { + type: RegistryPackageType.SDK, + canonicalName: 'example-package', + name: 'New Name', + mainDocsUrl: 'https://new-docs.example.com', + }; + const packageManifest = { + canonical: 'example-package', + name: 'Old Name', + main_docs_url: 'https://old-docs.example.com', + package_url: 'https://npmjs.com/package/example', + }; + + const updatedManifest = await target.getUpdatedManifest( + registryConfig, + packageManifest, + 'example-package', + '1.0.0', + 'abc123' + ); + + // Config values should override + expect(updatedManifest.name).toBe('New Name'); + expect(updatedManifest.main_docs_url).toBe('https://new-docs.example.com'); + // Existing value not in config should be preserved + expect(updatedManifest.package_url).toBe('https://npmjs.com/package/example'); + }); + + it('does not set optional fields when not specified in config', async () => { + const registryConfig: RegistryConfig = { + type: RegistryPackageType.SDK, + canonicalName: 'example-package', + }; + const packageManifest = { + canonical: 'example-package', + }; + + const updatedManifest = await target.getUpdatedManifest( + registryConfig, + packageManifest, + 'example-package', + '1.0.0', + 'abc123' + ); + + expect(updatedManifest.name).toBeUndefined(); + expect(updatedManifest.package_url).toBeUndefined(); + expect(updatedManifest.main_docs_url).toBeUndefined(); + expect(updatedManifest.api_docs_url).toBeUndefined(); + }); }); diff --git a/src/targets/registry.ts b/src/targets/registry.ts index c7994c99..81e49d91 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -29,6 +29,7 @@ import { getPackageManifest, updateManifestSymlinks, RegistryPackageType, + InitialManifestData, } from '../utils/registry'; import { isDryRun } from '../utils/helpers'; import { filterAsync, withRetry } from '../utils/async'; @@ -47,6 +48,14 @@ export interface RegistryConfig { checksums?: ChecksumEntry[]; /** Pattern that allows to skip the target if there's no matching file */ onlyIfPresent?: RegExp; + /** Human-readable name for new packages */ + name?: string; + /** Link to package registry (PyPI, npm, etc.) */ + packageUrl?: string; + /** Link to main documentation */ + mainDocsUrl?: string; + /** Link to API documentation */ + apiDocsUrl?: string; } interface LocalRegistry { @@ -371,6 +380,23 @@ export class RegistryTarget extends BaseTarget { created_at: new Date().toISOString(), }; + // Apply config fields - these always override existing values when specified + // This allows repo maintainers to update metadata in their config + const { owner, repo } = this.githubRepo; + updatedManifest.repo_url = `https://github.com/${owner}/${repo}`; + if (registryConfig.name !== undefined) { + updatedManifest.name = registryConfig.name; + } + if (registryConfig.packageUrl !== undefined) { + updatedManifest.package_url = registryConfig.packageUrl; + } + if (registryConfig.mainDocsUrl !== undefined) { + updatedManifest.main_docs_url = registryConfig.mainDocsUrl; + } + if (registryConfig.apiDocsUrl !== undefined) { + updatedManifest.api_docs_url = registryConfig.apiDocsUrl; + } + // Add file links if it's a generic app (legacy) if (registryConfig.type === RegistryPackageType.APP) { await this.addFileLinks( @@ -387,6 +413,26 @@ export class RegistryTarget extends BaseTarget { return updatedManifest; } + /** + * Builds the initial manifest data for creating a new package in the registry. + * + * @param registryConfig The registry configuration + * @returns The initial manifest data + */ + private buildInitialManifestData( + registryConfig: RegistryConfig + ): InitialManifestData { + const { owner, repo } = this.githubRepo; + return { + canonical: registryConfig.canonicalName, + repoUrl: `https://github.com/${owner}/${repo}`, + name: registryConfig.name, + packageUrl: registryConfig.packageUrl, + mainDocsUrl: registryConfig.mainDocsUrl, + apiDocsUrl: registryConfig.apiDocsUrl, + }; + } + /** * Commits the new version of the package to the release registry. * @@ -401,11 +447,13 @@ export class RegistryTarget extends BaseTarget { revision: string ): Promise { const canonicalName = registryConfig.canonicalName; + const initialManifestData = this.buildInitialManifestData(registryConfig); const { versionFilePath, packageManifest } = await getPackageManifest( localRepo.dir, registryConfig.type, canonicalName, - version + version, + initialManifestData ); const newManifest = await this.getUpdatedManifest( diff --git a/src/utils/__tests__/registry.test.ts b/src/utils/__tests__/registry.test.ts new file mode 100644 index 00000000..afc0e395 --- /dev/null +++ b/src/utils/__tests__/registry.test.ts @@ -0,0 +1,190 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; + +import { + getPackageManifest, + RegistryPackageType, + InitialManifestData, +} from '../registry'; + +describe('getPackageManifest', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'craft-registry-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('when package already exists', () => { + it('reads the existing latest.json manifest', async () => { + const packageDir = path.join(tempDir, 'packages', 'npm', '@sentry', 'browser'); + fs.mkdirSync(packageDir, { recursive: true }); + const existingManifest = { + canonical: 'npm:@sentry/browser', + name: 'Sentry Browser SDK', + version: '1.0.0', + repo_url: 'https://github.com/getsentry/sentry-javascript', + }; + fs.writeFileSync( + path.join(packageDir, 'latest.json'), + JSON.stringify(existingManifest) + ); + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/browser', + '1.1.0' + ); + + expect(result.packageManifest).toEqual(existingManifest); + expect(result.versionFilePath).toBe( + path.join(packageDir, '1.1.0.json') + ); + }); + + it('throws an error if version file already exists', async () => { + const packageDir = path.join(tempDir, 'packages', 'npm', '@sentry', 'browser'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'latest.json'), + JSON.stringify({ canonical: 'npm:@sentry/browser' }) + ); + fs.writeFileSync(path.join(packageDir, '1.0.0.json'), '{}'); + + await expect( + getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/browser', + '1.0.0' + ) + ).rejects.toThrow('Version file for "1.0.0" already exists'); + }); + }); + + describe('when package does not exist (new package)', () => { + it('creates directory structure and returns initial manifest', async () => { + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/wasm', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry WASM', + packageUrl: 'https://www.npmjs.com/package/@sentry/wasm', + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/', + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/wasm', + '0.1.0', + initialData + ); + + // Check directory was created + const packageDir = path.join(tempDir, 'packages', 'npm', '@sentry', 'wasm'); + expect(fs.existsSync(packageDir)).toBe(true); + + // Check manifest has correct fields + expect(result.packageManifest).toEqual({ + canonical: 'npm:@sentry/wasm', + repo_url: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry WASM', + package_url: 'https://www.npmjs.com/package/@sentry/wasm', + main_docs_url: 'https://docs.sentry.io/platforms/javascript/', + }); + + // Check version file path + expect(result.versionFilePath).toBe( + path.join(packageDir, '0.1.0.json') + ); + }); + + it('creates initial manifest with only required fields when optional are not provided', async () => { + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/minimal', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/minimal', + '1.0.0', + initialData + ); + + expect(result.packageManifest).toEqual({ + canonical: 'npm:@sentry/minimal', + repo_url: 'https://github.com/getsentry/sentry-javascript', + }); + }); + + it('throws an error when package does not exist and no initial data provided', async () => { + await expect( + getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/new-package', + '1.0.0' + ) + ).rejects.toThrow( + 'Package "npm:@sentry/new-package" does not exist in the registry and no initial manifest data was provided' + ); + }); + + it('works with APP type packages', async () => { + const initialData: InitialManifestData = { + canonical: 'app:craft', + repoUrl: 'https://github.com/getsentry/craft', + name: 'Craft', + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.APP, + 'app:craft', + '2.0.0', + initialData + ); + + // Check directory was created + const packageDir = path.join(tempDir, 'apps', 'craft'); + expect(fs.existsSync(packageDir)).toBe(true); + + expect(result.packageManifest).toEqual({ + canonical: 'app:craft', + repo_url: 'https://github.com/getsentry/craft', + name: 'Craft', + }); + }); + + it('includes apiDocsUrl when provided', async () => { + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/core', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + apiDocsUrl: 'https://docs.sentry.io/api/', + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/core', + '1.0.0', + initialData + ); + + expect(result.packageManifest).toEqual({ + canonical: 'npm:@sentry/core', + repo_url: 'https://github.com/getsentry/sentry-javascript', + api_docs_url: 'https://docs.sentry.io/api/', + }); + }); + }); +}); diff --git a/src/utils/registry.ts b/src/utils/registry.ts index 143e9530..b285a734 100644 --- a/src/utils/registry.ts +++ b/src/utils/registry.ts @@ -1,4 +1,4 @@ -import { promises as fsPromises, existsSync } from 'fs'; +import { promises as fsPromises, existsSync, mkdirSync } from 'fs'; import * as path from 'path'; import { logger } from '../logger'; @@ -15,25 +15,105 @@ export enum RegistryPackageType { SDK = 'sdk', } +/** Initial manifest data for creating new packages in the registry */ +export interface InitialManifestData { + /** The package's canonical name (e.g., "npm:@sentry/browser") */ + canonical: string; + /** Link to GitHub repo */ + repoUrl: string; + /** Human-readable name for the package */ + name?: string; + /** Link to package registry (PyPI, npm, etc.) */ + packageUrl?: string; + /** Link to main documentation */ + mainDocsUrl?: string; + /** Link to API documentation */ + apiDocsUrl?: string; +} + +/** + * Creates an initial manifest for a new package in the registry. + * + * @param initialData Data for the initial manifest + * @returns The initial package manifest object + */ +function createInitialManifest(initialData: InitialManifestData): { + [key: string]: any; +} { + const manifest: { [key: string]: any } = { + canonical: initialData.canonical, + repo_url: initialData.repoUrl, + }; + + if (initialData.name) { + manifest.name = initialData.name; + } + if (initialData.packageUrl) { + manifest.package_url = initialData.packageUrl; + } + if (initialData.mainDocsUrl) { + manifest.main_docs_url = initialData.mainDocsUrl; + } + if (initialData.apiDocsUrl) { + manifest.api_docs_url = initialData.apiDocsUrl; + } + + return manifest; +} + /** * Gets the package manifest version in the given directory. + * If the package doesn't exist yet, creates the directory structure and + * returns an initial manifest. * * @param baseDir Base directory for the registry clone - * @param packageDirPath The package directory. - * @param version The package version. + * @param type The type of the registry package (APP or SDK) + * @param canonicalName The package's canonical name + * @param version The package version + * @param initialManifestData Optional data for creating initial manifest for new packages */ export async function getPackageManifest( baseDir: string, type: RegistryPackageType, canonicalName: string, - version: string + version: string, + initialManifestData?: InitialManifestData ): Promise<{ versionFilePath: string; packageManifest: any }> { const packageDirPath = getPackageDirPath(type, canonicalName); - const versionFilePath = path.join(baseDir, packageDirPath, `${version}.json`); + const fullPackageDir = path.join(baseDir, packageDirPath); + const versionFilePath = path.join(fullPackageDir, `${version}.json`); + if (existsSync(versionFilePath)) { reportError(`Version file for "${version}" already exists. Aborting.`); } - const packageManifestPath = path.join(baseDir, packageDirPath, 'latest.json'); + + const packageManifestPath = path.join(fullPackageDir, 'latest.json'); + + // Check if this is a new package (no latest.json exists) + if (!existsSync(packageManifestPath)) { + if (!initialManifestData) { + reportError( + `Package "${canonicalName}" does not exist in the registry and no initial manifest data was provided.` + ); + } + + // Create directory structure if it doesn't exist + if (!existsSync(fullPackageDir)) { + logger.info( + `Creating new package directory for "${canonicalName}" at "${packageDirPath}"...` + ); + mkdirSync(fullPackageDir, { recursive: true }); + } + + logger.info( + `Creating initial manifest for new package "${canonicalName}"...` + ); + return { + versionFilePath, + packageManifest: createInitialManifest(initialManifestData), + }; + } + logger.debug('Reading the current configuration from', packageManifestPath); return { versionFilePath,