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
1 change: 1 addition & 0 deletions TARGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
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 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 -->
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.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"
]
}
116 changes: 116 additions & 0 deletions packages/targets/pkg-chocolatey/src/index.test.ts
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 &amp; Tool &lt;Pro&gt;</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);
});
});
175 changes: 175 additions & 0 deletions packages/targets/pkg-chocolatey/src/index.ts
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

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');
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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.',
],
}),
});
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/**/*"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.