From fa9670bc1bfaf1b9dfd74a51d0fd09146a29ea9c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 17:50:52 +0300 Subject: [PATCH 1/3] fix(registry): Auto-create package structure for new packages When introducing a new package to the registry, craft now automatically creates the required folder/file structure instead of failing with ENOENT. Changes: - Extended RegistryConfig with optional initial manifest fields (name, packageUrl, mainDocsUrl, apiDocsUrl) - Modified getPackageManifest to create directories and generate initial manifest when latest.json doesn't exist - Added InitialManifestData interface for type-safe initial manifest creation - repo_url is auto-derived from the GitHub repo configuration - Added comprehensive tests for the new package initialization flow Fixes #167 --- src/targets/registry.ts | 33 ++++- src/utils/__tests__/registry.test.ts | 190 +++++++++++++++++++++++++++ src/utils/registry.ts | 92 ++++++++++++- 3 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 src/utils/__tests__/registry.test.ts diff --git a/src/targets/registry.ts b/src/targets/registry.ts index c7994c99..e487362c 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 { @@ -387,6 +396,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 +430,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, From 7219ff457acdd41ebab6834e772392ebfdb9831d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 20:50:28 +0300 Subject: [PATCH 2/3] docs(registry): Document new package initialization feature --- docs/src/content/docs/targets/registry.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/src/content/docs/targets/registry.md b/docs/src/content/docs/targets/registry.md index bb524152..cf209db9 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. The `repo_url` field is automatically derived from your GitHub repository configuration. + +For a complete initial manifest, you can specify additional metadata: + +```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}}' +``` + +These fields (`name`, `packageUrl`, `mainDocsUrl`, `apiDocsUrl`) are only used when creating a new package for the first time. For existing packages, the manifest data is read from the registry. From 5e27080b5d08a9d7573c91237207350d47fb9bb9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 30 Dec 2025 21:43:09 +0300 Subject: [PATCH 3/3] fix(registry): Always apply config metadata fields to manifest Config fields (name, packageUrl, mainDocsUrl, apiDocsUrl) now always override existing manifest values when specified. This allows repo maintainers to update package metadata by changing their .craft.yml configuration, rather than only applying these fields for new packages. --- docs/src/content/docs/targets/registry.md | 8 +- src/targets/__tests__/registry.test.ts | 104 +++++++++++++++++++++- src/targets/registry.ts | 17 ++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/targets/registry.md b/docs/src/content/docs/targets/registry.md index cf209db9..96fe14e5 100644 --- a/docs/src/content/docs/targets/registry.md +++ b/docs/src/content/docs/targets/registry.md @@ -59,9 +59,7 @@ targets: ## 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. The `repo_url` field is automatically derived from your GitHub repository configuration. - -For a complete initial manifest, you can specify additional metadata: +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: @@ -74,4 +72,6 @@ targets: urlTemplate: 'https://example.com/{{version}}/{{file}}' ``` -These fields (`name`, `packageUrl`, `mainDocsUrl`, `apiDocsUrl`) are only used when creating a new package for the first time. For existing packages, the manifest data is read from the registry. +## 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 e487362c..81e49d91 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -380,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(