-
Notifications
You must be signed in to change notification settings - Fork 50
feat(targets): add pkg-chocolatey target (Chocolatey Community Repository) #475
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
Open
Alexander-Sorrell-IT
wants to merge
2
commits into
profullstack:master
Choose a base branch
from
Alexander-Sorrell-IT:feat/target-pkg-chocolatey
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 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 | ||
| ``` | ||
|
|
||
| <!-- Generated by scripts/gen-module-readmes.mjs --> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('<id>mytool</id>'); | ||
| expect(nuspec).toContain('<version>1.4.0</version>'); | ||
| expect(nuspec).toContain('<title>My Tool</title>'); | ||
| expect(nuspec).toContain('<authors>Acme</authors>'); | ||
| expect(nuspec).toContain('<projectUrl>https://example.com/mytool</projectUrl>'); | ||
| expect(nuspec).toContain('<tags>cli release</tags>'); | ||
|
|
||
| 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 <Pro>', | ||
| 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('<title>Zip & Tool <Pro></title>'); | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NonNullable<Config['installerType']>, string> = { | ||
| exe: '/S', | ||
| msi: '/qn /norestart', | ||
| zip: '', | ||
| }; | ||
|
|
||
| function xmlEscape(value: string): string { | ||
| return value | ||
| .replace(/&/g, '&') | ||
| .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 = [ | ||
| '<?xml version="1.0" encoding="utf-8"?>', | ||
| '<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">', | ||
| ' <metadata>', | ||
| ` <id>${xmlEscape(config.packageId)}</id>`, | ||
| ` <version>${xmlEscape(version)}</version>`, | ||
| ` <title>${xmlEscape(title)}</title>`, | ||
| ` <authors>${xmlEscape(authors)}</authors>`, | ||
| ` <owners>${xmlEscape(config.owners ?? authors)}</owners>`, | ||
| ]; | ||
| if (config.projectUrl) lines.push(` <projectUrl>${xmlEscape(config.projectUrl)}</projectUrl>`); | ||
| if (config.licenseUrl) lines.push(` <licenseUrl>${xmlEscape(config.licenseUrl)}</licenseUrl>`); | ||
| if (config.iconUrl) lines.push(` <iconUrl>${xmlEscape(config.iconUrl)}</iconUrl>`); | ||
| lines.push(` <summary>${xmlEscape(summary)}</summary>`); | ||
| lines.push(` <description>${xmlEscape(description)}</description>`); | ||
| if (config.tags?.length) lines.push(` <tags>${xmlEscape(config.tags.join(' '))}</tags>`); | ||
| lines.push(' </metadata>'); | ||
| lines.push(' <files>'); | ||
| lines.push(' <file src="tools\\**" target="tools" />'); | ||
| lines.push(' </files>'); | ||
| lines.push('</package>'); | ||
| 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 <CHOCOLATEY_API_KEY> --source https://push.chocolatey.org/`, | ||
| `choco push ${config.packageId}.${version}.nupkg --source https://push.chocolatey.org/`, | ||
| ]; | ||
| } | ||
|
|
||
| export default defineTarget<Config>({ | ||
| 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 <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.', | ||
| ], | ||
| }), | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/**/*"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.