From 4efa0237e81ad89d856b8dfae4822bd88000bc5c Mon Sep 17 00:00:00 2001 From: gujishh Date: Wed, 13 May 2026 18:42:34 +0900 Subject: [PATCH] feat(targets): generate Scoop manifests --- packages/targets/pkg-scoop/src/index.test.ts | 89 ++++++++++++++++++++ packages/targets/pkg-scoop/src/index.ts | 68 ++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/packages/targets/pkg-scoop/src/index.test.ts b/packages/targets/pkg-scoop/src/index.test.ts index 79771383..0ba0a6b7 100644 --- a/packages/targets/pkg-scoop/src/index.test.ts +++ b/packages/targets/pkg-scoop/src/index.test.ts @@ -1,4 +1,93 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; import { smokeTest } from '@profullstack/sh1pt-core/testing'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'pkg', requireKind: true }); + +describe('pkg-scoop build', () => { + it('writes a Scoop manifest from release metadata', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-scoop-')); + try { + const result = await adapter.build({ + projectDir: '/tmp/project', + outDir, + version: '1.2.3', + channel: 'stable', + env: {}, + secret: () => undefined, + log: () => {}, + }, { + appName: 'demo-cli', + urlTemplate: 'https://example.com/demo-cli-v{{version}}.zip', + hash: 'sha256:abc123', + bin: ['demo.exe'], + homepage: 'https://example.com/demo-cli', + license: 'MIT', + description: 'Demo CLI', + shortcuts: [{ target: 'demo.exe', name: 'Demo CLI', arguments: '--help' }], + envAddPath: 'bin', + }); + + expect(result.artifact).toBe(join(outDir, 'demo-cli.json')); + const manifest = JSON.parse(await readFile(result.artifact, 'utf8')); + expect(manifest).toMatchObject({ + version: '1.2.3', + description: 'Demo CLI', + homepage: 'https://example.com/demo-cli', + license: 'MIT', + url: 'https://example.com/demo-cli-v1.2.3.zip', + hash: 'abc123', + bin: ['demo.exe'], + env_add_path: 'bin', + }); + expect(manifest.shortcuts).toEqual([['demo.exe', 'Demo CLI', '--help']]); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }); + + it('supports architecture-specific downloads', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-scoop-')); + try { + const result = await adapter.build({ + projectDir: '/tmp/project', + outDir, + version: '2.0.0', + channel: 'stable', + env: {}, + secret: () => undefined, + log: () => {}, + }, { + appName: 'arch-cli', + url: 'https://example.com/arch-cli.zip', + architecture: { + '64bit': { url: 'https://example.com/arch-cli-x64.zip', hash: 'sha256:x64hash' }, + arm64: { url: 'https://example.com/arch-cli-arm64.zip', hash: 'armhash' }, + }, + }); + + const manifest = JSON.parse(await readFile(result.artifact, 'utf8')); + expect(manifest.architecture).toEqual({ + '64bit': { url: 'https://example.com/arch-cli-x64.zip', hash: 'x64hash' }, + arm64: { url: 'https://example.com/arch-cli-arm64.zip', hash: 'armhash' }, + }); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }); + + it('requires a download URL source', async () => { + await expect(adapter.build({ + projectDir: '/tmp/project', + outDir: '/tmp/out', + version: '1.0.0', + channel: 'stable', + env: {}, + secret: () => undefined, + log: () => {}, + }, { appName: 'missing-url' })).rejects.toThrow('pkg-scoop requires config.url or config.urlTemplate'); + }); +}); diff --git a/packages/targets/pkg-scoop/src/index.ts b/packages/targets/pkg-scoop/src/index.ts index 1752e136..5b223cb3 100644 --- a/packages/targets/pkg-scoop/src/index.ts +++ b/packages/targets/pkg-scoop/src/index.ts @@ -1,9 +1,27 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +interface Shortcut { + target: string; + name: string; + arguments?: string; +} + interface Config { appName: string; // e.g. "myapp" bucketRepo?: string; // GitHub repo for your scoop bucket, e.g. "myorg/scoop-bucket" urlTemplate?: string; // download URL template with {{version}} + url?: string; // explicit download URL, wins over urlTemplate + hash?: string; // sha256, or "sha256:" + bin?: string | string[]; // executable(s) exposed by Scoop + homepage?: string; + license?: string; + description?: string; + architecture?: Record; + shortcuts?: Shortcut[]; + notes?: string; + envAddPath?: string | string[]; } export default defineTarget({ @@ -11,9 +29,11 @@ export default defineTarget({ kind: 'package-manager', label: 'Scoop bucket', async build(ctx, config) { + const manifestPath = join(ctx.outDir, `${config.appName}.json`); ctx.log(`generate scoop manifest ${config.appName}.json for v${ctx.version}`); - // TODO: render JSON manifest with url, hash, bin, shortcuts - return { artifact: `${ctx.outDir}/${config.appName}.json` }; + await mkdir(ctx.outDir, { recursive: true }); + await writeFile(manifestPath, JSON.stringify(createManifest(ctx.version, config), null, 2) + '\n'); + return { artifact: manifestPath }; }, async ship(ctx, config) { const bucket = config.bucketRepo ?? 'profullstack/scoop-bucket'; @@ -41,3 +61,47 @@ export default defineTarget({ ], }), }); + +function createManifest(version: string, config: Config): Record { + const url = resolveUrl(version, config); + const manifest: Record = { + version, + description: config.description ?? `${config.appName} packaged by sh1pt`, + homepage: config.homepage, + license: config.license, + notes: config.notes, + url, + hash: normalizeHash(config.hash), + bin: config.bin ?? config.appName, + shortcuts: config.shortcuts?.map((shortcut) => [ + shortcut.target, + shortcut.name, + ...(shortcut.arguments ? [shortcut.arguments] : []), + ]), + env_add_path: config.envAddPath, + architecture: normalizeArchitecture(config.architecture), + }; + + return Object.fromEntries(Object.entries(manifest).filter(([, value]) => value !== undefined)); +} + +function resolveUrl(version: string, config: Config): string { + const url = config.url ?? config.urlTemplate?.replaceAll('{{version}}', version); + if (!url) throw new Error('pkg-scoop requires config.url or config.urlTemplate'); + return url; +} + +function normalizeHash(hash: string | undefined): string | undefined { + if (!hash) return undefined; + return hash.startsWith('sha256:') ? hash.slice('sha256:'.length) : hash; +} + +function normalizeArchitecture( + architecture: Config['architecture'], +): Record | undefined { + if (!architecture) return undefined; + return Object.fromEntries(Object.entries(architecture).map(([key, value]) => [ + key, + { url: value.url, hash: normalizeHash(value.hash) ?? value.hash }, + ])); +}