Skip to content
Merged
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
83 changes: 82 additions & 1 deletion packages/targets/pkg-winget/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,85 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
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('winget manifest generation', () => {
it('writes version, installer, and default locale manifests', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-winget-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({
outDir,
version: '1.2.3',
}) as any, {
packageId: 'Acme.MyTool',
publisher: 'Acme',
packageName: 'My Tool',
shortDescription: 'A command-line release tool',
homepage: 'https://example.com/my-tool',
license: 'MIT',
installerType: 'exe',
installers: [
{
architecture: 'x64',
url: 'https://downloads.example.com/my-tool-1.2.3-x64.exe',
sha256: 'a'.repeat(64),
scope: 'machine',
},
{
architecture: 'arm64',
url: 'https://downloads.example.com/my-tool-1.2.3-arm64.exe',
sha256: 'b'.repeat(64),
},
],
});

const manifestDir = join(outDir, 'manifests', 'a', 'Acme', 'MyTool', '1.2.3');
expect(result.artifact).toBe(manifestDir);

const versionManifest = await readFile(join(manifestDir, 'Acme.MyTool.yaml'), 'utf-8');
expect(versionManifest).toContain('PackageIdentifier: "Acme.MyTool"');
expect(versionManifest).toContain('PackageVersion: "1.2.3"');
expect(versionManifest).toContain('ManifestType: version');

const installerManifest = await readFile(join(manifestDir, 'Acme.MyTool.installer.yaml'), 'utf-8');
expect(installerManifest).toContain('InstallerType: "exe"');
expect(installerManifest).toContain('Architecture: "x64"');
expect(installerManifest).toContain('Scope: "machine"');
expect(installerManifest).toContain('Architecture: "arm64"');
expect(installerManifest).toContain('ManifestType: installer');

const localeManifest = await readFile(join(manifestDir, 'Acme.MyTool.locale.en-US.yaml'), 'utf-8');
expect(localeManifest).toContain('Publisher: "Acme"');
expect(localeManifest).toContain('PackageName: "My Tool"');
expect(localeManifest).toContain('ShortDescription: "A command-line release tool"');
expect(localeManifest).toContain('PackageUrl: "https://example.com/my-tool"');
expect(localeManifest).toContain('License: "MIT"');
});

it('keeps dry-run shipping side-effect free', async () => {
await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
dryRun: true,
}) as any, {
packageId: 'Acme.MyTool',
installers: [
{
architecture: 'x64',
url: 'https://downloads.example.com/my-tool-1.2.3-x64.exe',
sha256: 'c'.repeat(64),
},
],
})).resolves.toEqual({ id: 'dry-run' });
});
});
107 changes: 105 additions & 2 deletions packages/targets/pkg-winget/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,122 @@
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. "MyCompany.MyApp"
publisher?: string;
installerType?: 'exe' | 'msi' | 'msix' | 'zip' | 'portable';
packageName?: string;
shortDescription?: string;
homepage?: string;
license?: string;
defaultLocale?: string;
manifestVersion?: string;
installers: {
architecture: 'x64' | 'x86' | 'arm64' | 'arm';
url: string;
sha256: string;
scope?: 'user' | 'machine';
}[];
}

function yamlString(value: string): string {
return JSON.stringify(value);
}

function manifestDir(outDir: string, packageId: string, version: string): string {
const [publisher = packageId, name = packageId] = packageId.split('.');
return join(outDir, 'manifests', publisher[0]!.toLowerCase(), publisher, name, version);
}

function renderVersionManifest(config: Config, version: string): string {
const locale = config.defaultLocale ?? 'en-US';
const manifestVersion = config.manifestVersion ?? '1.6.0';
return [
`PackageIdentifier: ${yamlString(config.packageId)}`,
`PackageVersion: ${yamlString(version)}`,
`DefaultLocale: ${yamlString(locale)}`,
'ManifestType: version',
`ManifestVersion: ${yamlString(manifestVersion)}`,
'',
].join('\n');
}

function renderInstallerManifest(config: Config, version: string): string {
if (!config.installers?.length) {
throw new Error('winget manifest generation requires at least one installer');
}

const manifestVersion = config.manifestVersion ?? '1.6.0';
const installerType = config.installerType ?? 'exe';
const lines = [
`PackageIdentifier: ${yamlString(config.packageId)}`,
`PackageVersion: ${yamlString(version)}`,
`InstallerType: ${yamlString(installerType)}`,
'Installers:',
];

for (const installer of config.installers) {
lines.push(` - Architecture: ${yamlString(installer.architecture)}`);
lines.push(` InstallerUrl: ${yamlString(installer.url)}`);
lines.push(` InstallerSha256: ${yamlString(installer.sha256)}`);
if (installer.scope) {
lines.push(` Scope: ${yamlString(installer.scope)}`);
}
}

lines.push('ManifestType: installer');
lines.push(`ManifestVersion: ${yamlString(manifestVersion)}`);
lines.push('');
return lines.join('\n');
}

function renderLocaleManifest(config: Config, version: string): string {
const locale = config.defaultLocale ?? 'en-US';
const manifestVersion = config.manifestVersion ?? '1.6.0';
const packageName = config.packageName ?? config.packageId.split('.').at(-1) ?? config.packageId;
const publisher = config.publisher ?? config.packageId.split('.')[0] ?? 'Unknown';
const lines = [
`PackageIdentifier: ${yamlString(config.packageId)}`,
`PackageVersion: ${yamlString(version)}`,
`PackageLocale: ${yamlString(locale)}`,
`Publisher: ${yamlString(publisher)}`,
`PackageName: ${yamlString(packageName)}`,
`ShortDescription: ${yamlString(config.shortDescription ?? `${packageName} release`)}`,
];

if (config.homepage) {
lines.push(`PackageUrl: ${yamlString(config.homepage)}`);
}
if (config.license) {
lines.push(`License: ${yamlString(config.license)}`);
}

lines.push('ManifestType: defaultLocale');
lines.push(`ManifestVersion: ${yamlString(manifestVersion)}`);
lines.push('');
return lines.join('\n');
}

export default defineTarget<Config>({
id: 'pkg-winget',
kind: 'package-manager',
label: 'Microsoft winget',
async build(ctx, config) {
const dir = manifestDir(ctx.outDir, config.packageId, ctx.version);
const baseName = config.packageId;
ctx.log(`generate winget manifest for ${config.packageId} v${ctx.version}`);
// TODO: render YAML manifests (version, installer, locale) from template
return { artifact: `${ctx.outDir}/manifests/${config.packageId}` };
await mkdir(dir, { recursive: true });
await Promise.all([
writeFile(join(dir, `${baseName}.yaml`), renderVersionManifest(config, ctx.version), 'utf-8'),
writeFile(join(dir, `${baseName}.installer.yaml`), renderInstallerManifest(config, ctx.version), 'utf-8'),
writeFile(
join(dir, `${baseName}.locale.${config.defaultLocale ?? 'en-US'}.yaml`),
renderLocaleManifest(config, ctx.version),
'utf-8',
),
]);
return { artifact: dir };
},
async ship(ctx, config) {
ctx.log(`submit winget PR for ${config.packageId}@${ctx.version}`);
Expand Down
Loading