Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/targets/pkg-chocolatey/README.md
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
```
25 changes: 25 additions & 0 deletions packages/targets/pkg-chocolatey/package.json
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"
]
}
67 changes: 67 additions & 0 deletions packages/targets/pkg-chocolatey/src/index.test.ts
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' });
});
});
197 changes: 197 additions & 0 deletions packages/targets/pkg-chocolatey/src/index.ts
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

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);
Comment on lines +63 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Double XML-escaping on fallback defaults

authors and description are already XML-escaped at their assignment sites, but then passed as the fallback value into another escapeXml() call. Any user who sets authors (or description) with special characters like &, <, or > but omits owners (or summary) will end up with double-encoded output — e.g. &amp;lt; instead of &lt; — producing a malformed .nuspec that 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.

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 }];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty checksum in auto-generated installer entry

When config.installers is not provided, the auto-generated entry has sha256: ''. By default, Chocolatey enforces checksum validation and will immediately fail choco install with an error like "Checksum was empty" or "Checksum mismatch". This makes the zero-config path non-functional. At minimum, checksumType64 should be set to 'none' when the checksum is empty (though that disables security), or the caller should be required to supply installers.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 portable type produces invalid Install-ChocolateyPackage call

When installerType is 'portable', the code falls into the else branch and emits fileType = 'portable' plus silentArgs = "/S", then calls Install-ChocolateyPackage. However, Install-ChocolateyPackage only accepts fileType values of exe or msi; passing 'portable' causes Chocolatey to throw immediately. Portable packages in Chocolatey do not download an installer at all — they typically copy a binary and call Install-BinFile (or rely on automatic shimming). A separate code path is needed for this case.

}

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 ship() returns a success result without performing any work

The non-dry-run path of ship() immediately returns { id, url } as if the package was pushed, but nothing is actually executed — no .nupkg is packed, no choco push is run, and CHOCOLATEY_API_KEY is never consumed. Callers (and users) will see a "success" with a valid-looking url while their package was never actually submitted. The TODO is acknowledged, but the early success return makes this a silent no-op rather than a clear failure or not-implemented signal.

},

async status(id) {
const [name] = id.split('@');
return { state: 'live', url: `https://community.chocolatey.org/packages/${name}` };
Comment on lines +182 to +184
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 status() always reports 'live' regardless of actual registry state

status() returns { state: 'live' } unconditionally without querying the Chocolatey API. Any package id — including one that was never published, was moderated-rejected, or was unlisted — will appear as live. If status is polled by CI to gate a deployment, this will always succeed falsely.

},

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',
],
}),
});
5 changes: 5 additions & 0 deletions packages/targets/pkg-chocolatey/tsconfig.json
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/**/*"]
}
Loading