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 @@ -23,6 +23,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status
| `pkg-snap` | Snapcraft | 🚧 |
| `pkg-flatpak` | Flathub | ✅ |
| `pkg-aur` | Arch User Repo | 🚧 |
| `pkg-pacman` | Arch custom repo (repo-add) | ✅ |
| `pkg-nix` | nixpkgs | 🚧 |

### Desktop
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-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-pacman', '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
44 changes: 44 additions & 0 deletions packages/targets/pkg-pacman/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Pacman / Arch custom repository

Provides the Pacman / Arch custom 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.
- Implements publish behavior used by sh1pt promotion or release workflows.

## Package

- Name: `@profullstack/sh1pt-target-pkg-pacman`
- Path: `packages/targets/pkg-pacman`
- Adapter ID: `pkg-pacman`
- 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-pacman
```

## Development

```bash
pnpm --filter @profullstack/sh1pt-target-pkg-pacman typecheck
```

Run tests from the repository root when this module includes a test file:

```bash
pnpm vitest run packages/targets/pkg-pacman/src/index.test.ts
```

<!-- Generated by scripts/gen-module-readmes.mjs -->
17 changes: 17 additions & 0 deletions packages/targets/pkg-pacman/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@profullstack/sh1pt-target-pkg-pacman",
"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-pacman" },
"homepage": "https://sh1pt.com",
"bugs": "https://github.com/profullstack/sh1pt/issues",
"files": ["dist"]
}
125 changes: 125 additions & 0 deletions packages/targets/pkg-pacman/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing';
import { execFileSync } from 'node:child_process';
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('pacman PKGBUILD generation', () => {
it('writes a valid PKGBUILD with normalized pkgver and a pacman.conf snippet', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-'));
tempDirs.push(outDir);

const result = await adapter.build(fakeBuildContext({ outDir, version: 'v1.5.0-rc1' }) as any, {
pkgname: 'myapp',
pkgdesc: 'My App',
url: 'https://example.com/myapp',
license: ['MIT'],
depends: ['glibc'],
makedepends: ['git'],
sourceUrl: 'https://example.com/myapp-1.5.0.tar.gz',
sha256sum: 'a'.repeat(64),
repoName: 'sovereign',
});

const pkgbuildPath = join(outDir, 'pacman', 'PKGBUILD');
expect(result.artifact).toBe(pkgbuildPath);

const pkgbuild = await readFile(pkgbuildPath, 'utf-8');
expect(pkgbuild).toContain('pkgname=myapp');
// pacman pkgver may not contain '-' or ':' — normalized to '_'.
expect(pkgbuild).toContain('pkgver=1.5.0_rc1');
expect(pkgbuild).toContain('pkgrel=1');
expect(pkgbuild).toContain("arch=('x86_64')");
expect(pkgbuild).toContain("license=('MIT')");
expect(pkgbuild).toContain("depends=('glibc')");
expect(pkgbuild).toContain('source=("$pkgname-$pkgver.tar.gz::https://example.com/myapp-1.5.0.tar.gz")');
expect(pkgbuild).toContain(`sha256sums=('${'a'.repeat(64)}')`);
expect(pkgbuild).toContain('package() {');

const conf = await readFile(join(outDir, 'pacman', 'sovereign.pacman.conf'), 'utf-8');
expect(conf).toContain('[sovereign]');
expect(conf).toContain('Server = ');
});

it('escapes shell metacharacters in pkgdesc and defaults source to empty arrays', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-'));
tempDirs.push(outDir);

await adapter.build(fakeBuildContext({ outDir, version: '2.0.0' }) as any, {
pkgname: 'myapp',
pkgdesc: 'Has "quotes" and $VARS and `backticks`',
});

const pkgbuild = await readFile(join(outDir, 'pacman', 'PKGBUILD'), 'utf-8');
// Double-quote, $ and backtick are escaped so the bash PKGBUILD stays valid.
expect(pkgbuild).toContain('pkgdesc="Has \\"quotes\\" and \\$VARS and \\`backticks\\`"');
// No source provided -> empty arrays, not a broken reference.
expect(pkgbuild).toContain('source=()');
expect(pkgbuild).toContain('sha256sums=()');
});

it('keeps dry-run shipping side-effect free; live publish throws not-implemented', async () => {
const ship = await adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: true }) as any, {
pkgname: 'myapp',
repoName: 'sovereign',
});
expect(ship.id).toBe('dry-run');
expect((ship.meta?.commands as string[])?.some((c) => c.startsWith('repo-add'))).toBe(true);

await expect(adapter.ship(fakeShipContext({ version: '1.5.0', dryRun: false }) as any, {
pkgname: 'myapp',
})).rejects.toThrow(/PACMAN_GPG_KEY|not implemented/i);
});

it('generates syntactically valid bash (bash -n) even with hostile field values', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-'));
tempDirs.push(outDir);

await adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, {
pkgname: 'myapp',
pkgdesc: 'evil"; rm -rf /; echo "$(whoami)` `',
url: 'https://x.test/$(id)`uname`',
sha256sum: "ab'cd",
});

// bash -n parses without executing — proves the generated PKGBUILD is valid bash.
const out = execFileSync('bash', ['-n', join(outDir, 'pacman', 'PKGBUILD')], { encoding: 'utf-8', stdio: 'pipe' });
expect(out).toBe('');
});

it('rejects pkgname / repoName that would break the PKGBUILD or pacman.conf', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-pacman-'));
tempDirs.push(outDir);

await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, {
pkgname: 'bad name; rm -rf /',
})).rejects.toThrow(/invalid pkgname/i);

await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, {
pkgname: 'myapp',
repoName: 'evil]\n[core',
})).rejects.toThrow(/invalid repoName/i);

// repoBaseUrl with a newline would inject a fake [section] into pacman.conf.
await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, {
pkgname: 'myapp',
repoBaseUrl: 'https://x.test\n[core]\nServer = https://evil.test',
})).rejects.toThrow(/invalid repoBaseUrl/i);

// pkgrel must be a positive integer (it's embedded unquoted in the PKGBUILD).
await expect(adapter.build(fakeBuildContext({ outDir, version: '1.0.0' }) as any, {
pkgname: 'myapp',
pkgrel: '1\nextrafield=injected',
})).rejects.toThrow(/invalid pkgrel/i);
});
});
181 changes: 181 additions & 0 deletions packages/targets/pkg-pacman/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { defineTarget, manualSetup } from '@profullstack/sh1pt-core';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

type PacmanArch = 'x86_64' | 'aarch64' | 'any';

interface Config {
pkgname: string; // e.g. "myapp"
pkgdesc?: string;
url?: string; // homepage
license?: string[]; // e.g. ["MIT"]
pkgrel?: string; // default "1"
arch?: PacmanArch[]; // default ["x86_64"]
depends?: string[];
makedepends?: string[];
sourceUrl?: string; // upstream source tarball
sha256sum?: string; // checksum of the source ("SKIP" if none)
repoName?: string; // custom pacman repo db name (NOT the AUR)
repoBaseUrl?: string; // where the repo db is hosted (Server= in pacman.conf)
}

// pacman pkgver may not contain '-' or ':'. Strip leading v, map '-'/':' to '_'.
function pkgver(version: string): string {
return version.replace(/^v/, '').replace(/[-:]/g, '_');
}

function arches(config: Config): PacmanArch[] {
return config.arch ?? ['x86_64'];
}

// Escape a value for a double-quoted bash string in the PKGBUILD.
function shEscape(value: string): string {
return value.replace(/(["`$\\])/g, '\\$1');
}

function bashArray(values: string[]): string {
return values.map((v) => `'${v.replace(/'/g, "'\\''")}'`).join(' ');
}

// Reject inputs that could produce an invalid PKGBUILD or inject into the
// pacman.conf [section] header / shell commands. pkgname follows Arch rules;
// the repo db/section name allows only [A-Za-z0-9_-].
function assertValidNames(config: Config): void {
if (!/^[a-z0-9][a-z0-9@._+-]*$/.test(config.pkgname)) {
throw new Error(
`pkg-pacman: invalid pkgname '${config.pkgname}' — Arch pkgnames are lowercase ` +
'alphanumerics plus @ . _ + -, and may not start with "-" or "."',
);
}
const repo = config.repoName ?? config.pkgname;
if (!/^[A-Za-z0-9_-]+$/.test(repo)) {
throw new Error(
`pkg-pacman: invalid repoName '${repo}' — only letters, digits, "-" and "_" ` +
'are allowed (it becomes a pacman.conf [section] header and a repo db filename)',
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// repoBaseUrl flows verbatim into the .pacman.conf "Server =" line and into the
// single-quoted rsync command — a newline injects a fake [section] and a single
// quote breaks out of the rsync quoting.
if (config.repoBaseUrl !== undefined && /[\r\n']/.test(config.repoBaseUrl)) {
throw new Error('pkg-pacman: invalid repoBaseUrl — must not contain newlines or single-quote characters');
}
// pkgrel is embedded unquoted in the PKGBUILD; Arch requires a positive integer.
const rel = config.pkgrel ?? '1';
if (!/^[1-9][0-9]*$/.test(rel)) {
throw new Error(`pkg-pacman: invalid pkgrel '${rel}' — must be a positive integer`);
}
}

function renderPkgbuild(ctx: { version: string }, config: Config): string {
const ver = pkgver(ctx.version);
const rel = config.pkgrel ?? '1';
const desc = config.pkgdesc ?? `Release package for ${config.pkgname}`;
const license = config.license ?? ['MIT'];
const lines = [
'# Maintainer: sh1pt <release@sh1pt.com>',
`pkgname=${config.pkgname}`,
`pkgver=${ver}`,
`pkgrel=${rel}`,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
`pkgdesc="${shEscape(desc)}"`,
`arch=(${bashArray(arches(config))})`,
];
if (config.url) lines.push(`url="${shEscape(config.url)}"`);
lines.push(`license=(${bashArray(license)})`);
if (config.depends?.length) lines.push(`depends=(${bashArray(config.depends)})`);
if (config.makedepends?.length) lines.push(`makedepends=(${bashArray(config.makedepends)})`);
if (config.sourceUrl) {
lines.push(`source=("$pkgname-$pkgver.tar.gz::${shEscape(config.sourceUrl)}")`);
lines.push(`sha256sums=(${bashArray([config.sha256sum ?? 'SKIP'])})`);
} else {
lines.push('source=()');
lines.push('sha256sums=()');
}
lines.push('');
lines.push('build() {');
lines.push(' # TODO: project build steps (make, cargo build --release, go build, …)');
lines.push(' :');
lines.push('}');
lines.push('');
lines.push('package() {');
lines.push(' # TODO: install built artifacts into "$pkgdir"');
lines.push(' :');
lines.push('}');
lines.push('');
return lines.join('\n');
}

function renderRepoConf(config: Config): string {
const repo = config.repoName ?? config.pkgname;
const server = config.repoBaseUrl ?? `https://pacman.sh1pt.com/${repo}/$arch`;
return [
`# Add to /etc/pacman.conf to consume the custom ${repo} repository:`,
`[${repo}]`,
`Server = ${server}`,
'',
].join('\n');
}

function publishCommands(ctx: { version: string }, config: Config): string[] {
const repo = config.repoName ?? config.pkgname;
const ver = pkgver(ctx.version);
const rel = config.pkgrel ?? '1';
const arch = arches(config)[0] ?? 'x86_64';
const pkgfile = `${config.pkgname}-${ver}-${rel}-${arch}.pkg.tar.zst`;
const dest = config.repoBaseUrl ?? `pacman.sh1pt.com:/var/www/pacman/${repo}`;
return [
'makepkg -s --sign',
`repo-add --sign '${repo}.db.tar.zst' '${pkgfile}'`,
`rsync ./ '${dest}/'`,
];
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

export default defineTarget<Config>({
id: 'pkg-pacman',
kind: 'package-manager',
label: 'Pacman / Arch custom repository',
async build(ctx, config) {
assertValidNames(config);
const pkgbuildPath = join(ctx.outDir, 'pacman', 'PKGBUILD');
const confPath = join(ctx.outDir, 'pacman', `${config.repoName ?? config.pkgname}.pacman.conf`);
ctx.log(`generate PKGBUILD for ${config.pkgname} v${ctx.version} [${arches(config).join(', ')}]`);
await mkdir(dirname(pkgbuildPath), { recursive: true });
await Promise.all([
writeFile(pkgbuildPath, renderPkgbuild(ctx, config), 'utf-8'),
writeFile(confPath, renderRepoConf(config), 'utf-8'),
]);
return {
artifact: pkgbuildPath,
meta: { pkgbuild: pkgbuildPath, repoConf: confPath, commands: publishCommands(ctx, config) },
};
},
async ship(ctx, config) {
assertValidNames(config);
const repo = config.repoName ?? config.pkgname;
ctx.log(`publish ${config.pkgname}@${ctx.version} to pacman repo (${repo})`);
if (ctx.dryRun) return { id: 'dry-run', meta: { commands: publishCommands(ctx, config) } };
// Live publish (makepkg + repo-add + signed upload) is not implemented yet —
// fail loudly rather than report a false success. (PACMAN_GPG_KEY is
// documented in setup(); not checked here since the path is unimplemented.)
throw new Error(
`pkg-pacman live publish for ${config.pkgname} is not implemented yet — ` +
'use dryRun to preview the makepkg + repo-add commands.',
);
},
async status(id) {
const name = id.split('@')[0] ?? id;
return { state: 'in-review', url: `https://pacman.sh1pt.com/${name}/` };
},
setup: manualSetup({
label: 'Pacman / Arch custom repository',
vendorDocUrl: 'https://man.archlinux.org/man/repo-add.8.en',
steps: [
'This is a custom pacman binary repo (repo-add), NOT the AUR — for the AUR use pkg-aur.',
'Generate a signing key: gpg --full-generate-key',
'Run: sh1pt secret set PACMAN_GPG_KEY "$(gpg --export-secret-keys --armor <key-id>)"',
'Run: sh1pt secret set PACMAN_GPG_PASSPHRASE <passphrase>',
'Build needs an Arch-like host with base-devel (makepkg) + the repo-add tool.',
'Consumers add the [repo] + Server line (generated .pacman.conf) to /etc/pacman.conf.',
],
}),
});
5 changes: 5 additions & 0 deletions packages/targets/pkg-pacman/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