diff --git a/TARGETS.md b/TARGETS.md index 2b7f76d1..5bb1d238 100644 --- a/TARGETS.md +++ b/TARGETS.md @@ -20,6 +20,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status | `pkg-winget` | Microsoft winget | 🚧 | | `pkg-scoop` | Scoop bucket | 🚧 | | `pkg-apt` | apt repo / PPA | 🚧 | +| `pkg-dnf` | dnf repo / Fedora COPR | ✅ | | `pkg-snap` | Snapcraft | 🚧 | | `pkg-flatpak` | Flathub | ✅ | | `pkg-aur` | Arch User Repo | 🚧 | diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 6f78ff5d..d121e47d 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -162,7 +162,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'targets', pkgPrefix: '@profullstack/sh1pt-target', description: 'Distribution targets — stores, registries, CDNs, deploy platforms', - adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], + adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-dnf', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], }, { id: 'vcs', diff --git a/packages/targets/pkg-dnf/README.md b/packages/targets/pkg-dnf/README.md new file mode 100644 index 00000000..b4ec4a82 --- /dev/null +++ b/packages/targets/pkg-dnf/README.md @@ -0,0 +1,43 @@ +# dnf repo / Fedora COPR + +Provides the dnf repo / Fedora COPR sh1pt target adapter for build, publish, or deployment workflows. + +## What it does + +- Registers a target surface that sh1pt can build, publish, or deploy to. +- Provides adapter metadata and lifecycle hooks used by the CLI. +- Includes setup guidance for required credentials or provider configuration. +- Implements build behavior used by sh1pt target workflows. + +## Package + +- Name: `@profullstack/sh1pt-target-pkg-dnf` +- Path: `packages/targets/pkg-dnf` +- Adapter ID: `pkg-dnf` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-target-pkg-dnf +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-target-pkg-dnf typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/targets/pkg-dnf/src/index.test.ts +``` + + diff --git a/packages/targets/pkg-dnf/package.json b/packages/targets/pkg-dnf/package.json new file mode 100644 index 00000000..d4023e21 --- /dev/null +++ b/packages/targets/pkg-dnf/package.json @@ -0,0 +1,17 @@ +{ + "name": "@profullstack/sh1pt-target-pkg-dnf", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { "@profullstack/sh1pt-core": "workspace:*" }, + "license": "MIT", + "repository": { "type": "git", "url": "git+https://github.com/profullstack/sh1pt.git", "directory": "packages/targets/pkg-dnf" }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": ["dist"] +} diff --git a/packages/targets/pkg-dnf/src/index.test.ts b/packages/targets/pkg-dnf/src/index.test.ts new file mode 100644 index 00000000..2136b625 --- /dev/null +++ b/packages/targets/pkg-dnf/src/index.test.ts @@ -0,0 +1,81 @@ +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'pkg', requireKind: true }); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('dnf / RPM spec generation', () => { + it('writes an RPM spec and a self-hosted .repo file', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-dnf-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0-rc1' }) as any, { + packageName: 'myapp', + summary: 'My App', + license: 'Apache-2.0', + url: 'https://example.com/myapp', + requires: ['glibc', 'openssl'], + }); + + const specPath = join(outDir, 'rpm', 'myapp.spec'); + expect(result.artifact).toBe(specPath); + + const spec = await readFile(specPath, 'utf-8'); + expect(spec).toContain('Name: myapp'); + // 'v' stripped and '-' replaced so the RPM version is valid. + expect(spec).toContain('Version: 1.5.0.rc1'); + expect(spec).toContain('Release: 1%{?dist}'); + expect(spec).toContain('License: Apache-2.0'); + expect(spec).toContain('Requires: glibc'); + expect(spec).toContain('Requires: openssl'); + expect(spec).toContain('%description'); + expect(spec).toContain('%changelog'); + // %changelog header must be valid RPM form: "* Wed May 29 2026 Name - ver-rel" + expect(spec).toMatch(/^\* (Sun|Mon|Tue|Wed|Thu|Fri|Sat) [A-Z][a-z]{2} \d{2} \d{4} sh1pt - .+-.+$/m); + + const repo = await readFile(join(outDir, 'rpm', 'myapp.repo'), 'utf-8'); + expect(repo).toContain('[myapp]'); + expect(repo).toContain('gpgcheck=1'); + expect(repo).toContain('dnf.sh1pt.com'); + }); + + it('targets Fedora COPR when coprProject is set', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-dnf-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, { + packageName: 'myapp', + coprProject: 'acme/myapp', + }); + + const repo = await readFile(join(outDir, 'rpm', 'myapp.repo'), 'utf-8'); + expect(repo).toContain('download.copr.fedorainfracloud.org/results/acme/myapp'); + // gpgkey must point at the COPR project's pubkey, not the self-hosted fallback. + expect(repo).toContain('gpgkey=https://download.copr.fedorainfracloud.org/results/acme/myapp/pubkey.gpg'); + + // COPR publish path uses copr-cli with the documented `status ` form. + expect(result.meta?.commands).toContain('copr-cli status '); + expect((result.meta?.commands as string[])?.some((c: string) => c.startsWith('copr-cli build acme/myapp'))).toBe(true); + }); + + it('keeps dry-run shipping side-effect free and surfaces build commands', async () => { + const ship = await adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, { + packageName: 'myapp', + }); + expect(ship.id).toBe('dry-run'); + expect((ship.meta?.commands as string[])?.some((c: string) => c.startsWith('rpmbuild'))).toBe(true); + + await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: false }) as any, { + packageName: 'myapp', + })).rejects.toThrow(/not implemented/i); + }); +}); diff --git a/packages/targets/pkg-dnf/src/index.ts b/packages/targets/pkg-dnf/src/index.ts new file mode 100644 index 00000000..6e2e28ee --- /dev/null +++ b/packages/targets/pkg-dnf/src/index.ts @@ -0,0 +1,159 @@ +import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +type RpmArch = 'x86_64' | 'aarch64' | 'noarch'; + +interface Config { + packageName: string; // e.g. "myapp" + summary?: string; + description?: string; + license?: string; // SPDX, e.g. "MIT" + url?: string; // homepage + release?: string; // RPM release field, default "1" + arch?: RpmArch[]; // default ["x86_64"] + requires?: string[]; // runtime Requires + sourceUrl?: string; // Source0 tarball URL + coprProject?: string; // "owner/project" — publish via Fedora COPR + repoBaseUrl?: string; // self-hosted dnf repo base URL (for the .repo file) +} + +// RPM versions may not contain '-' (it separates version from release). +function rpmVersion(version: string): string { + return version.replace(/^v/, '').replace(/-/g, '.'); +} + +function arches(config: Config): RpmArch[] { + return config.arch ?? ['x86_64']; +} + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// RPM %changelog requires the exact header form: "* Wed May 29 2026 Name - ver-rel". +// Built locale-independently so the .spec is accepted by rpmbuild. +function rpmChangelogDate(d: Date): string { + return `${WEEKDAYS[d.getUTCDay()]} ${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, '0')} ${d.getUTCFullYear()}`; +} + +function renderSpec(ctx: { version: string }, config: Config): string { + const version = rpmVersion(ctx.version); + const release = config.release ?? '1'; + const summary = config.summary ?? `Release package for ${config.packageName}`; + const license = config.license ?? 'MIT'; + const buildArch = arches(config).includes('noarch') ? 'noarch' : undefined; + const lines = [ + `Name: ${config.packageName}`, + `Version: ${version}`, + `Release: ${release}%{?dist}`, + `Summary: ${summary}`, + `License: ${license}`, + ]; + if (config.url) lines.push(`URL: ${config.url}`); + if (config.sourceUrl) lines.push(`Source0: ${config.sourceUrl}`); + if (buildArch) lines.push(`BuildArch: ${buildArch}`); + for (const req of config.requires ?? []) lines.push(`Requires: ${req}`); + lines.push(''); + lines.push('%description'); + lines.push(config.description ?? `${summary}. Generated by sh1pt for dnf/RPM publishing.`); + lines.push(''); + lines.push('%prep'); + lines.push('%autosetup -n %{name}-%{version}'); + lines.push(''); + lines.push('%build'); + lines.push('# TODO: project build steps (make, cargo, go build, …)'); + lines.push(''); + lines.push('%install'); + lines.push('# TODO: install built artifacts into %{buildroot}'); + lines.push(''); + lines.push('%files'); + lines.push('# TODO: list packaged files'); + lines.push(''); + lines.push('%changelog'); + lines.push(`* ${rpmChangelogDate(new Date())} sh1pt - ${version}-${release}`); + lines.push(`- Automated release ${version}`); + lines.push(''); + return lines.join('\n'); +} + +function renderRepoFile(config: Config): string { + const baseUrl = config.coprProject + ? `https://download.copr.fedorainfracloud.org/results/${config.coprProject}/fedora-$releasever-$basearch/` + : (config.repoBaseUrl ?? `https://dnf.sh1pt.com/${config.packageName}/fedora-$releasever-$basearch/`); + return [ + `[${config.packageName}]`, + `name=${config.packageName}`, + `baseurl=${baseUrl}`, + 'enabled=1', + 'gpgcheck=1', + `gpgkey=${config.coprProject + ? `https://download.copr.fedorainfracloud.org/results/${config.coprProject}/pubkey.gpg` + : `${config.repoBaseUrl ?? `https://dnf.sh1pt.com/${config.packageName}`}/RPM-GPG-KEY-${config.packageName}`}`, + '', + ].join('\n'); +} + +function publishCommands(ctx: { version: string }, config: Config): string[] { + const specFile = `${config.packageName}.spec`; + if (config.coprProject) { + return [ + `rpmbuild -bs ${specFile}`, + `copr-cli build ${config.coprProject} ~/rpmbuild/SRPMS/${config.packageName}-${rpmVersion(ctx.version)}-${config.release ?? '1'}*.src.rpm`, + `copr-cli status `, + ]; + } + return [ + `rpmbuild -bb ${specFile}`, + `createrepo_c `, + `gpg --detach-sign --armor /repodata/repomd.xml`, + `rsync / ${config.repoBaseUrl ?? 'dnf.sh1pt.com:/var/www/dnf'}/`, + ]; +} + +export default defineTarget({ + id: 'pkg-dnf', + kind: 'package-manager', + label: 'dnf repo / Fedora COPR', + async build(ctx, config) { + const specPath = join(ctx.outDir, 'rpm', `${config.packageName}.spec`); + const repoPath = join(ctx.outDir, 'rpm', `${config.packageName}.repo`); + ctx.log(`generate RPM spec for ${config.packageName} v${ctx.version} [${arches(config).join(', ')}]`); + await mkdir(dirname(specPath), { recursive: true }); + await Promise.all([ + writeFile(specPath, renderSpec(ctx, config), 'utf-8'), + writeFile(repoPath, renderRepoFile(config), 'utf-8'), + ]); + return { + artifact: specPath, + meta: { specFile: specPath, repoFile: repoPath, commands: publishCommands(ctx, config) }, + }; + }, + async ship(ctx, config) { + const via = config.coprProject ? `COPR ${config.coprProject}` : (config.repoBaseUrl ?? 'dnf.sh1pt.com'); + ctx.log(`publish ${config.packageName}@${ctx.version} to ${via}`); + if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } }; + // Live publish (rpmbuild + copr-cli build | createrepo_c + GPG-sign + upload) + // is not implemented yet — fail loudly rather than report a false success. + // (Required secrets are documented in setup(); not checked here since the + // path is unimplemented.) + throw new Error( + `pkg-dnf live publish for ${config.packageName} is not implemented yet — ` + + `use dryRun to preview the ${config.coprProject ? 'rpmbuild + copr-cli' : 'rpmbuild + createrepo_c'} commands.`, + ); + }, + async status(id) { + const name = id.split('@')[0] ?? id; + return { state: 'in-review', url: `https://dnf.sh1pt.com/${name}/` }; + }, + setup: manualSetup({ + label: 'dnf repo / Fedora COPR', + vendorDocUrl: 'https://rpm-packaging-guide.github.io/', + steps: [ + 'COPR (hosted): create an account at https://copr.fedorainfracloud.org and a project.', + 'COPR: copy the API token from https://copr.fedorainfracloud.org/api/ and run: sh1pt secret set COPR_API_TOKEN ', + 'Self-hosted: generate a signing key (gpg --full-generate-key) and run: sh1pt secret set DNF_GPG_KEY "$(gpg --export-secret-keys --armor )"', + 'Self-hosted: run: sh1pt secret set DNF_GPG_PASSPHRASE ', + 'Build needs rpmbuild + createrepo_c (or copr-cli) installed on a Fedora/RHEL-like host.', + ], + }), +}); diff --git a/packages/targets/pkg-dnf/tsconfig.json b/packages/targets/pkg-dnf/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/targets/pkg-dnf/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5378866..62b68a78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1832,6 +1832,12 @@ importers: specifier: workspace:* version: link:../../core + packages/targets/pkg-dnf: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/targets/pkg-docker: dependencies: '@profullstack/sh1pt-core':