diff --git a/TARGETS.md b/TARGETS.md index 2b7f76d1..78ff1d68 100644 --- a/TARGETS.md +++ b/TARGETS.md @@ -19,6 +19,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status | `pkg-homebrew` | Homebrew tap | ✅ | | `pkg-winget` | Microsoft winget | 🚧 | | `pkg-scoop` | Scoop bucket | 🚧 | +| `pkg-chocolatey` | Chocolatey Community Repository | ✅ | | `pkg-apt` | apt repo / PPA | 🚧 | | `pkg-snap` | Snapcraft | 🚧 | | `pkg-flatpak` | Flathub | ✅ | diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 6f78ff5d..b9aff481 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-chocolatey', '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'], }, { id: 'vcs', diff --git a/packages/targets/pkg-chocolatey/README.md b/packages/targets/pkg-chocolatey/README.md new file mode 100644 index 00000000..7144136d --- /dev/null +++ b/packages/targets/pkg-chocolatey/README.md @@ -0,0 +1,43 @@ +# Chocolatey Community Repository + +Provides the Chocolatey Community Repository 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-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` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-target-pkg-chocolatey +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-target-pkg-chocolatey typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/targets/pkg-chocolatey/src/index.test.ts +``` + + diff --git a/packages/targets/pkg-chocolatey/package.json b/packages/targets/pkg-chocolatey/package.json new file mode 100644 index 00000000..019dcb6a --- /dev/null +++ b/packages/targets/pkg-chocolatey/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-target-pkg-chocolatey", + "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-chocolatey" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ] +} diff --git a/packages/targets/pkg-chocolatey/src/index.test.ts b/packages/targets/pkg-chocolatey/src/index.test.ts new file mode 100644 index 00000000..2a9b0303 --- /dev/null +++ b/packages/targets/pkg-chocolatey/src/index.test.ts @@ -0,0 +1,116 @@ +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('chocolatey package generation', () => { + it('writes a nuspec and an exe install script with checksum', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-choco-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ outDir, version: '1.4.0' }) as any, { + packageId: 'mytool', + packageTitle: 'My Tool', + authors: 'Acme', + projectUrl: 'https://example.com/mytool', + licenseUrl: 'https://example.com/license', + tags: ['cli', 'release'], + summary: 'A release tool', + installerUrl: 'https://downloads.example.com/mytool-1.4.0.exe', + installerType: 'exe', + checksum: 'a'.repeat(64), + }); + + const dir = join(outDir, 'chocolatey', 'mytool'); + expect(result.artifact).toBe(dir); + + const nuspec = await readFile(join(dir, 'mytool.nuspec'), 'utf-8'); + expect(nuspec).toContain('mytool'); + expect(nuspec).toContain('1.4.0'); + expect(nuspec).toContain('My Tool'); + expect(nuspec).toContain('Acme'); + expect(nuspec).toContain('https://example.com/mytool'); + expect(nuspec).toContain('cli release'); + + const install = await readFile(join(dir, 'tools', 'chocolateyinstall.ps1'), 'utf-8'); + expect(install).toContain("packageName = 'mytool'"); + expect(install).toContain("fileType = 'exe'"); + expect(install).toContain("url = 'https://downloads.example.com/mytool-1.4.0.exe'"); + expect(install).toContain(`checksum = '${'a'.repeat(64)}'`); + expect(install).toContain("checksumType = 'sha256'"); + expect(install).toContain("silentArgs = '/S'"); + expect(install).toContain('Install-ChocolateyPackage @packageArgs'); + }); + + it('escapes XML and emits a zip install script for zip installers', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-choco-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, { + packageId: 'ziptool', + packageTitle: 'Zip & Tool ', + installerUrl: 'https://downloads.example.com/ziptool-2.0.0.zip', + installerType: 'zip', + checksum: 'b'.repeat(64), + checksumType: 'sha512', + }); + + const dir = join(outDir, 'chocolatey', 'ziptool'); + const nuspec = await readFile(join(dir, 'ziptool.nuspec'), 'utf-8'); + expect(nuspec).toContain('Zip & Tool <Pro>'); + + const install = await readFile(join(dir, 'tools', 'chocolateyinstall.ps1'), 'utf-8'); + expect(install).toContain('Install-ChocolateyZipPackage @packageArgs'); + expect(install).toContain("unzipLocation = $toolsDir"); + expect(install).toContain("checksumType = 'sha512'"); + }); + + it('keeps dry-run shipping side-effect free and surfaces the push commands', async () => { + const ship = await adapter.ship(fakeShipContext({ version: '1.4.0', dryRun: true }) as any, { + packageId: 'mytool', + installerUrl: 'https://downloads.example.com/mytool-1.4.0.exe', + checksum: 'c'.repeat(64), + }); + expect(ship.id).toBe('dry-run'); + expect(ship.meta?.commands).toContain('choco push mytool.1.4.0.nupkg --source https://push.chocolatey.org/'); + }); + + it("escapes single quotes in user-overridable PowerShell fields", async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-choco-')); + tempDirs.push(outDir); + + await adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, { + packageId: 'mytool', + installerUrl: "https://downloads.example.com/o'reilly-1.0.0.exe", + installerType: 'exe', + checksum: 'd'.repeat(64), + silentArgs: "/VERYSILENT /DIR='C:\\Program Files'", + }); + + const install = await readFile(join(outDir, 'chocolatey', 'mytool', 'tools', 'chocolateyinstall.ps1'), 'utf-8'); + // Single quotes are doubled so the generated .ps1 stays valid PowerShell. + expect(install).toContain("url = 'https://downloads.example.com/o''reilly-1.0.0.exe'"); + expect(install).toContain("silentArgs = '/VERYSILENT /DIR=''C:\\Program Files'''"); + // Every literal quote is doubled, so the count of ' is even on each value line. + const silentLine = install.split('\n').find((l) => l.includes('silentArgs')) ?? ''; + expect((silentLine.match(/'/g) ?? []).length % 2).toBe(0); + }); + + it('throws (no false success) on live, unimplemented publish', async () => { + await expect(adapter.ship(fakeShipContext({ version: '1.0.0', dryRun: false }) as any, { + packageId: 'mytool', + installerUrl: 'https://downloads.example.com/mytool-1.0.0.exe', + checksum: 'e'.repeat(64), + })).rejects.toThrow(/not implemented/i); + }); +}); diff --git a/packages/targets/pkg-chocolatey/src/index.ts b/packages/targets/pkg-chocolatey/src/index.ts new file mode 100644 index 00000000..a91e0c81 --- /dev/null +++ b/packages/targets/pkg-chocolatey/src/index.ts @@ -0,0 +1,175 @@ +import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +interface Config { + packageId: string; // e.g. "mytool" (lowercase, the community-repo id) + packageTitle?: string; // human title, e.g. "My Tool" + authors?: string; // defaults to packageId + owners?: string; + projectUrl?: string; // homepage + licenseUrl?: string; + iconUrl?: string; + tags?: string[]; + summary?: string; + description?: string; + installerUrl: string; // download URL for the installer/zip + installerType?: 'exe' | 'msi' | 'zip'; + checksum: string; // checksum of the installer at installerUrl + checksumType?: 'sha256' | 'sha512'; + silentArgs?: string; // override the default silent-install args +} + +const TYPE_DEFAULT_SILENT_ARGS: Record, string> = { + exe: '/S', + msi: '/qn /norestart', + zip: '', +}; + +function xmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function packageDir(outDir: string, packageId: string): string { + return join(outDir, 'chocolatey', packageId); +} + +function silentArgsFor(config: Config): string { + return config.silentArgs ?? TYPE_DEFAULT_SILENT_ARGS[config.installerType ?? 'exe']; +} + +function renderNuspec(config: Config, version: string): string { + const authors = config.authors ?? config.packageId; + const title = config.packageTitle ?? config.packageId; + const summary = config.summary ?? `${title} release`; + const description = config.description ?? summary; + const lines = [ + '', + '', + ' ', + ` ${xmlEscape(config.packageId)}`, + ` ${xmlEscape(version)}`, + ` ${xmlEscape(title)}`, + ` ${xmlEscape(authors)}`, + ` ${xmlEscape(config.owners ?? authors)}`, + ]; + if (config.projectUrl) lines.push(` ${xmlEscape(config.projectUrl)}`); + if (config.licenseUrl) lines.push(` ${xmlEscape(config.licenseUrl)}`); + if (config.iconUrl) lines.push(` ${xmlEscape(config.iconUrl)}`); + lines.push(` ${xmlEscape(summary)}`); + lines.push(` ${xmlEscape(description)}`); + if (config.tags?.length) lines.push(` ${xmlEscape(config.tags.join(' '))}`); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(''); + lines.push(''); + return lines.join('\n'); +} + +// Escape a value for a single-quoted PowerShell string literal: a literal ' +// must be doubled ('') or it terminates the string and breaks the .ps1. +function psEscape(value: string): string { + return value.replace(/'/g, "''"); +} + +function renderInstallScript(config: Config): string { + const type = config.installerType ?? 'exe'; + const checksumType = config.checksumType ?? 'sha256'; + if (type === 'zip') { + return [ + `$ErrorActionPreference = 'Stop'`, + `$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition`, + `$packageArgs = @{`, + ` packageName = '${psEscape(config.packageId)}'`, + ` unzipLocation = $toolsDir`, + ` url = '${psEscape(config.installerUrl)}'`, + ` checksum = '${psEscape(config.checksum)}'`, + ` checksumType = '${checksumType}'`, + `}`, + `Install-ChocolateyZipPackage @packageArgs`, + '', + ].join('\n'); + } + return [ + `$ErrorActionPreference = 'Stop'`, + `$packageArgs = @{`, + ` packageName = '${psEscape(config.packageId)}'`, + ` fileType = '${type}'`, + ` url = '${psEscape(config.installerUrl)}'`, + ` checksum = '${psEscape(config.checksum)}'`, + ` checksumType = '${checksumType}'`, + ` silentArgs = '${psEscape(silentArgsFor(config))}'`, + ` validExitCodes = @(0)`, + `}`, + `Install-ChocolateyPackage @packageArgs`, + '', + ].join('\n'); +} + +function publishCommands(config: Config, version: string): string[] { + return [ + `choco pack ${config.packageId}.nuspec --version ${version}`, + `choco apikey --api-key --source https://push.chocolatey.org/`, + `choco push ${config.packageId}.${version}.nupkg --source https://push.chocolatey.org/`, + ]; +} + +export default defineTarget({ + id: 'pkg-chocolatey', + kind: 'package-manager', + label: 'Chocolatey Community Repository', + async build(ctx, config) { + if (!config.installerUrl) throw new Error('pkg-chocolatey requires installerUrl'); + if (!config.checksum) throw new Error('pkg-chocolatey requires checksum (the community repo rejects un-checksummed remote downloads)'); + const dir = packageDir(ctx.outDir, config.packageId); + const toolsDir = join(dir, 'tools'); + ctx.log(`generate chocolatey package for ${config.packageId} v${ctx.version}`); + await mkdir(toolsDir, { recursive: true }); + await Promise.all([ + writeFile(join(dir, `${config.packageId}.nuspec`), renderNuspec(config, ctx.version), 'utf-8'), + writeFile(join(toolsDir, 'chocolateyinstall.ps1'), renderInstallScript(config), 'utf-8'), + ]); + return { + artifact: dir, + meta: { + nuspec: join(dir, `${config.packageId}.nuspec`), + installScript: join(toolsDir, 'chocolateyinstall.ps1'), + commands: publishCommands(config, ctx.version), + }, + }; + }, + async ship(ctx, config) { + ctx.log(`push ${config.packageId}@${ctx.version} to community.chocolatey.org`); + if (ctx.dryRun) { + return { id: 'dry-run', meta: { commands: publishCommands(config, ctx.version) } }; + } + // Live publish (choco pack + choco push) is not implemented yet — fail + // loudly rather than return a false success. Needs Windows + the choco CLI + // and CHOCOLATEY_API_KEY in the vault. + throw new Error( + 'pkg-chocolatey live publish is not implemented yet — use dryRun to preview ' + + 'the choco pack/push commands. (Requires Windows + choco CLI + CHOCOLATEY_API_KEY.)', + ); + }, + async status(id) { + const [pkgId] = id.split('@'); + return { state: 'in-review', url: `https://community.chocolatey.org/packages/${pkgId}` }; + }, + setup: manualSetup({ + label: 'Chocolatey Community Repository', + vendorDocUrl: 'https://docs.chocolatey.org/en-us/create/create-packages/', + steps: [ + 'Create an account at https://community.chocolatey.org and confirm your email.', + 'Copy your API key from https://community.chocolatey.org/account', + 'Run: sh1pt secret set CHOCOLATEY_API_KEY ', + 'Packaging + push require Windows with the choco CLI installed (choco pack / choco push).', + 'Note: community submissions pass through automated validation, verification, and VirusTotal scanning before they go live.', + ], + }), +}); diff --git a/packages/targets/pkg-chocolatey/tsconfig.json b/packages/targets/pkg-chocolatey/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/targets/pkg-chocolatey/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..d11ff256 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1826,6 +1826,12 @@ importers: specifier: workspace:* version: link:../../core + packages/targets/pkg-chocolatey: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/targets/pkg-deno: dependencies: '@profullstack/sh1pt-core':