-
Notifications
You must be signed in to change notification settings - Fork 51
feat(targets): add pkg-chocolatey target (Chocolatey Community Repository) #484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8f6df6d
a556847
9e6abbf
f8dd548
b1b4c4c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Chocolatey Community Repository | ||
|
|
||
| Provides the Chocolatey Community Repository sh1pt target adapter, enabling automated | ||
| `.nuspec` manifest and `chocolateyInstall.ps1` generation and publishing. | ||
|
|
||
| ## What it does | ||
|
|
||
| - Generates a valid `.nuspec` XML manifest for your package | ||
| - Generates a `tools/chocolateyInstall.ps1` script to download and install your app | ||
| - Publishes the `.nupkg` to the [Chocolatey Community Repository](https://community.chocolatey.org) | ||
| via your API key | ||
|
|
||
| ## Package | ||
|
|
||
| - Name: `@profullstack/sh1pt-target-pkg-chocolatey` | ||
| - Path: `packages/targets/pkg-chocolatey` | ||
| - Adapter ID: `pkg-chocolatey` | ||
| - Homepage: https://sh1pt.com | ||
|
|
||
| ## Scripts | ||
|
|
||
| - `build`: `tsc -p tsconfig.json` | ||
| - `prepublishOnly`: `pnpm build` | ||
| - `typecheck`: `tsc -p tsconfig.json --noEmit` | ||
|
|
||
| ## Setup | ||
|
|
||
| ```bash | ||
| sh1pt secret set CHOCOLATEY_API_KEY <your-api-key> | ||
| ``` | ||
|
|
||
| 1. Create a free account at [community.chocolatey.org](https://community.chocolatey.org) | ||
| 2. Generate an API key under your account settings | ||
| 3. Run the `sh1pt secret set` command above | ||
|
|
||
| See the [Chocolatey package creation docs](https://docs.chocolatey.org/en-us/create/create-packages) | ||
| for more detail. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| pnpm add @profullstack/sh1pt-target-pkg-chocolatey | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "name": "@profullstack/sh1pt-target-pkg-chocolatey", | ||
| "version": "0.1.0", | ||
| "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-chocolatey" | ||
| }, | ||
| "homepage": "https://sh1pt.com", | ||
| "bugs": "https://github.com/profullstack/sh1pt/issues", | ||
| "files": [ | ||
| "dist" | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; | ||
| import { readFile, rm, mkdtemp } 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('Chocolatey package generation', () => { | ||
| it('writes a .nuspec and chocolateyInstall.ps1 from config', async () => { | ||
| const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-choco-')); | ||
| tempDirs.push(outDir); | ||
|
|
||
| const result = await adapter.build(fakeBuildContext({ | ||
| outDir, | ||
| version: 'v2.1.0', | ||
| }) as any, { | ||
| packageId: 'myapp', | ||
| title: 'My App', | ||
| authors: 'ACME Corp', | ||
| owners: 'acmecorp', | ||
| homepage: 'https://example.com', | ||
| license: 'MIT', | ||
| description: 'An example CLI tool', | ||
| summary: 'CLI tool for doing things', | ||
| tags: 'cli tool example', | ||
| installerType: 'zip', | ||
| releaseRepo: 'acme/myapp', | ||
| installers: [ | ||
| { architecture: 'x64', url: 'https://github.com/acme/myapp/releases/download/v2.1.0/myapp-2.1.0-x64.zip', sha256: 'a'.repeat(64) }, | ||
| ], | ||
| }); | ||
|
|
||
| expect(result.artifact).toBe(join(outDir, 'myapp.nuspec')); | ||
|
|
||
| const nuspec = await readFile(join(outDir, 'myapp.nuspec'), 'utf-8'); | ||
| expect(nuspec).toContain('<id>myapp</id>'); | ||
| expect(nuspec).toContain('<version>2.1.0</version>'); | ||
| expect(nuspec).toContain('<title>My App</title>'); | ||
| expect(nuspec).toContain('<authors>ACME Corp</authors>'); | ||
| expect(nuspec).toContain('<owners>acmecorp</owners>'); | ||
| expect(nuspec).toContain('<projectUrl>https://example.com</projectUrl>'); | ||
| expect(nuspec).toContain('<description>An example CLI tool</description>'); | ||
| expect(nuspec).toContain('<tags>cli tool example</tags>'); | ||
|
|
||
| const installScript = await readFile(join(outDir, 'tools', 'chocolateyInstall.ps1'), 'utf-8'); | ||
| expect(installScript).toContain('https://github.com/acme/myapp/releases/download/v2.1.0/myapp-2.1.0-x64.zip'); | ||
| expect(installScript).toContain('a'.repeat(64)); | ||
| expect(installScript).toContain('Install-ChocolateyZipPackage'); | ||
| }); | ||
|
|
||
| it('keeps dry-run shipping side-effect free', async () => { | ||
| await expect(adapter.ship(fakeShipContext({ | ||
| version: '2.1.0', | ||
| dryRun: true, | ||
| }) as any, { | ||
| packageId: 'myapp', | ||
| })).resolves.toEqual({ id: 'dry-run' }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; | ||
| import { mkdir, writeFile } from 'node:fs/promises'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| interface InstallerConfig { | ||
| /** Architecture: x64 | x86 | arm64 */ | ||
| architecture?: 'x64' | 'x86' | 'arm64'; | ||
| /** Direct download URL for this architecture */ | ||
| url: string; | ||
| /** SHA-256 checksum of the downloaded file */ | ||
| sha256: string; | ||
| } | ||
|
|
||
| interface Config { | ||
| /** Chocolatey package id, e.g. "myapp" */ | ||
| packageId: string; | ||
| /** Package title shown in the Chocolatey gallery */ | ||
| title?: string; | ||
| /** Package author(s) */ | ||
| authors?: string; | ||
| /** Package owner (usually your Chocolatey username) */ | ||
| owners?: string; | ||
| /** Project homepage URL */ | ||
| homepage?: string; | ||
| /** SPDX license identifier, e.g. "MIT" */ | ||
| license?: string; | ||
| /** Package description (shown on the gallery page) */ | ||
| description?: string; | ||
| /** Short one-line summary */ | ||
| summary?: string; | ||
| /** Space-separated tags */ | ||
| tags?: string; | ||
| /** Installer type: zip | exe | msi | portable */ | ||
| installerType?: 'zip' | 'exe' | 'msi' | 'portable'; | ||
| /** Installer entries per architecture (falls back to a single x64 entry). */ | ||
| installers?: InstallerConfig[]; | ||
| /** GitHub release repo used to derive a default download URL, e.g. "myorg/myapp" */ | ||
| releaseRepo?: string; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| function escapeXml(s: string): string { | ||
| return s | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| function defaultUrl(config: Config, version: string, arch: string): string { | ||
| const repo = config.releaseRepo ?? config.packageId; | ||
| const ext = config.installerType === 'exe' ? 'exe' : config.installerType === 'msi' ? 'msi' : 'zip'; | ||
| return `https://github.com/${repo}/releases/download/v${version}/${config.packageId}-${version}-${arch}.${ext}`; | ||
| } | ||
|
|
||
| function renderNuspec(config: Config, version: string): string { | ||
| const id = config.packageId; | ||
| const title = config.title ?? id; | ||
| const authors = escapeXml(config.authors ?? title); | ||
| const owners = escapeXml(config.owners ?? authors); | ||
| const homepage = escapeXml(config.homepage ?? 'https://sh1pt.com'); | ||
| const license = escapeXml(config.license ?? 'MIT'); | ||
| const description = escapeXml(config.description ?? `${title} package`); | ||
| const summary = escapeXml(config.summary ?? description); | ||
| const tags = escapeXml(config.tags ?? 'cli'); | ||
|
|
||
| return [ | ||
| '<?xml version="1.0" encoding="utf-8"?>', | ||
| '<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">', | ||
| ' <metadata>', | ||
| ` <id>${escapeXml(id)}</id>`, | ||
| ` <version>${escapeXml(version)}</version>`, | ||
| ` <title>${escapeXml(title)}</title>`, | ||
| ` <authors>${authors}</authors>`, | ||
| ` <owners>${owners}</owners>`, | ||
| ` <projectUrl>${homepage}</projectUrl>`, | ||
| ` <licenseUrl>https://opensource.org/licenses/${escapeXml(config.license ?? 'MIT')}</licenseUrl>`, | ||
| ' <requireLicenseAcceptance>false</requireLicenseAcceptance>', | ||
| ` <description>${description}</description>`, | ||
| ` <summary>${summary}</summary>`, | ||
| ` <tags>${tags}</tags>`, | ||
| ' </metadata>', | ||
| ' <files>', | ||
| ' <file src="tools\\**" target="tools" />', | ||
| ' </files>', | ||
| '</package>', | ||
| '', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| function renderInstallScript(config: Config, version: string): string { | ||
| const installers = config.installers ?? [{ url: defaultUrl(config, version, 'x64'), sha256: '', architecture: 'x64' as const }]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
|
|
||
| const primary = installers.find((i) => i.architecture !== 'x86') ?? installers[0]!; | ||
| const x86 = installers.find((i) => i.architecture === 'x86'); | ||
| const installerType = config.installerType ?? 'zip'; | ||
|
|
||
| const lines = [ | ||
| '$ErrorActionPreference = \'Stop\'', | ||
| '$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"', | ||
| '', | ||
| '$packageArgs = @{', | ||
| ' packageName = $env:ChocolateyPackageName', | ||
| ]; | ||
|
|
||
| if (installerType === 'zip') { | ||
| lines.push(` url64bit = '${primary.url}'`); | ||
| lines.push(` checksum64 = '${primary.sha256}'`); | ||
| lines.push(' checksumType64 = \'sha256\''); | ||
| if (x86) { | ||
| lines.push(` url = '${x86.url}'`); | ||
| lines.push(` checksum = '${x86.sha256}'`); | ||
| lines.push(' checksumType = \'sha256\''); | ||
| } | ||
| lines.push(' unzipLocation = $toolsDir'); | ||
| lines.push('}'); | ||
| lines.push(''); | ||
| lines.push('Install-ChocolateyZipPackage @packageArgs'); | ||
| } else { | ||
| lines.push(` url64bit = '${primary.url}'`); | ||
| lines.push(` checksum64 = '${primary.sha256}'`); | ||
| lines.push(' checksumType64 = \'sha256\''); | ||
| lines.push(` fileType = \'${installerType}\'`); | ||
| lines.push(' silentArgs = "/S"'); | ||
| lines.push(' validExitCodes = @(0)'); | ||
| lines.push('}'); | ||
| lines.push(''); | ||
| lines.push('Install-ChocolateyPackage @packageArgs'); | ||
|
Comment on lines
+123
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
| } | ||
|
|
||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Target definition | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export default defineTarget<Config>({ | ||
| id: 'pkg-chocolatey', | ||
| kind: 'package-manager', | ||
| label: 'Chocolatey Community Repository', | ||
|
|
||
| async build(ctx, config) { | ||
| const version = ctx.version.replace(/^v/, ''); | ||
| const nuspecPath = join(ctx.outDir, `${config.packageId}.nuspec`); | ||
| const toolsDir = join(ctx.outDir, 'tools'); | ||
| const installScriptPath = join(toolsDir, 'chocolateyInstall.ps1'); | ||
|
|
||
| ctx.log(`generate Chocolatey package ${config.packageId} v${version}`); | ||
|
|
||
| await mkdir(toolsDir, { recursive: true }); | ||
| await writeFile(nuspecPath, renderNuspec(config, version), 'utf-8'); | ||
| await writeFile(installScriptPath, renderInstallScript(config, version), 'utf-8'); | ||
|
|
||
| ctx.log(`wrote ${nuspecPath}`); | ||
| ctx.log(`wrote ${installScriptPath}`); | ||
|
|
||
| return { artifact: nuspecPath }; | ||
| }, | ||
|
|
||
| async ship(ctx, config) { | ||
| const version = ctx.version.replace(/^v/, ''); | ||
| const packageId = config.packageId; | ||
|
|
||
| ctx.log(`push ${packageId} v${version} to Chocolatey Community Repository`); | ||
|
|
||
| if (ctx.dryRun) return { id: 'dry-run' }; | ||
|
|
||
| // TODO: pack the nupkg and push via `choco push` or the Chocolatey v2 API | ||
| // Requires CHOCOLATEY_API_KEY from ctx.secret('CHOCOLATEY_API_KEY') | ||
| return { | ||
| id: `${packageId}@${version}`, | ||
| url: `https://community.chocolatey.org/packages/${packageId}/${version}`, | ||
| }; | ||
|
Comment on lines
+166
to
+179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The non-dry-run path of |
||
| }, | ||
|
|
||
| async status(id) { | ||
| const [name] = id.split('@'); | ||
| return { state: 'live', url: `https://community.chocolatey.org/packages/${name}` }; | ||
|
Comment on lines
+182
to
+184
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, | ||
|
|
||
| setup: manualSetup({ | ||
| label: 'Chocolatey Community Repository', | ||
| vendorDocUrl: 'https://docs.chocolatey.org/en-us/create/create-packages', | ||
| steps: [ | ||
| 'Create a free account at community.chocolatey.org', | ||
| 'Generate an API key under your account settings', | ||
| 'Run: sh1pt secret set CHOCOLATEY_API_KEY <your-api-key>', | ||
| 'sh1pt will pack and push your .nupkg to the community repository on each release', | ||
| ], | ||
| }), | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "extends": "../../../tsconfig.base.json", | ||
| "compilerOptions": { "outDir": "dist", "rootDir": "src" }, | ||
| "include": ["src/**/*"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
authorsanddescriptionare already XML-escaped at their assignment sites, but then passed as the fallback value into anotherescapeXml()call. Any user who setsauthors(ordescription) with special characters like&,<, or>but omitsowners(orsummary) will end up with double-encoded output — e.g.&lt;instead of<— producing a malformed.nuspecthat Chocolatey's validator will reject.Fix: capture the raw config values first, then escape once at the point of interpolation, or store pre-escaped and pre-defaulted values without re-escaping.