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
76 changes: 75 additions & 1 deletion packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
Expand Down Expand Up @@ -151,3 +151,77 @@ describe('skills marketplaces --json', () => {
}
});
});

describe('skills new command', () => {
let tempDir: string;
let stdout: string[];
let stderr: string[];

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'sh1pt-skills-new-'));
stdout = [];
stderr = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(' '));
});
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
stderr.push(args.map(String).join(' '));
});
});

afterEach(() => {
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});

function writeSkillFile() {
const skillDir = join(tempDir, 'skill');
mkdirSync(skillDir, { recursive: true });
const skillFile = join(skillDir, 'SKILL.md');
writeFileSync(skillFile, [
'---',
'name: invoice-helper',
'description: Helps prepare invoices',
'---',
'',
'# Invoice Helper',
'',
].join('\n'));
return skillFile;
}

async function runNew(price: string) {
const newCmd = skillsCmd.commands.find((c) => c.name() === 'new')!;
const skillFile = writeSkillFile();
const out = join(tempDir, 'sh1pt.skill.json');
await newCmd.parseAsync([
'--skill-file', skillFile,
'--out', out,
'--price', price,
], { from: 'user' });
return out;
}

it('writes valid integer prices to the manifest and marketplace command', async () => {
const out = await runNew('100');
const manifest = JSON.parse(readFileSync(out, 'utf8'));

expect(manifest.price).toBe(100);
expect(manifest.marketplaces.ugig.command).toContain('--price 100');
expect(stdout.join('\n')).toContain('wrote');
});

it.each(['-5', '1.9', '5abc', '1e2', '0x10', '+5', `${Number.MAX_SAFE_INTEGER + 1}`])(
'rejects invalid listing price %s before writing a manifest',
async (price) => {
const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => {
throw new Error(`process.exit(${code})`);
}) as never);

await expect(runNew(price)).rejects.toThrow('process.exit(1)');
expect(stderr.join('\n')).toContain('--price');
expect(existsSync(join(tempDir, 'sh1pt.skill.json'))).toBe(false);
expect(exit).toHaveBeenCalledWith(1);
},
);
});
30 changes: 23 additions & 7 deletions packages/cli/src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ async function saveManifest(path: string, manifest: SkillManifest): Promise<void
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}

function parsePriceSats(raw: string): number {
const value = raw.trim();
if (!/^\d+$/.test(value)) {
Comment on lines +115 to +116

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 trim() silently accepts whitespace-padded input (e.g., --price " 5 " normalises to 5 without any warning). More subtly, it also means leading-zero strings like "007" pass the regex unchanged and resolve to 7. If the intent is strict validation of what the user typed, both cases should be rejected rather than coerced. Removing trim() and testing raw directly would make the function's rejection contract completely unambiguous.

Suggested change
const value = raw.trim();
if (!/^\d+$/.test(value)) {
if (!/^\d+$/.test(raw)) {

throw new Error(`--price must be a non-negative integer in sats. Got: ${JSON.stringify(raw)}`);
}

const price = Number(value);

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 If trim() is removed (so raw is tested directly), the Number(value) conversion should also use raw to stay consistent — keeping value alive solely to pass into Number() while the regex already ran on raw would be confusing. The suggestion below aligns both checks on the same variable.

Suggested change
const price = Number(value);
const price = Number(raw);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

if (!Number.isSafeInteger(price)) {
throw new Error(`--price must be a safe non-negative integer in sats. Got: ${JSON.stringify(raw)}`);
}

return price;
}

function normalizeText(text: string): string {
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
Expand Down Expand Up @@ -357,10 +371,11 @@ skillsCmd
.option('--price <sats>', 'Price in sats; 0 = free', '0')
.option('--source-url <url>', 'Public raw SKILL.md or repo URL')
.action(async (opts: { skillFile: string; out: string; name?: string; title?: string; description?: string; tagline?: string; category: string; tags: string; price: string; sourceUrl?: string }) => {
// Validate price before writing any files.
const parsedPrice = Number.parseInt(opts.price, 10);
if (!Number.isInteger(parsedPrice) || parsedPrice < 0 || opts.price.includes('.') || Number.isNaN(parsedPrice)) {
console.error(kleur.red(`--price must be a non-negative integer (sats). Got: ${JSON.stringify(opts.price)}`));
let price: number;
try {
price = parsePriceSats(opts.price);
} catch (error) {
console.error(kleur.red(error instanceof Error ? error.message : String(error)));
process.exit(1);
}

Expand All @@ -369,17 +384,18 @@ skillsCmd
const name = slugify(opts.name ?? inferred.name ?? basename(dirname(skillFile)));
const title = opts.title ?? inferred.title ?? name;
const description = opts.description ?? inferred.description ?? `Agent skill: ${title}`;
const tags = opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10);
const manifest: SkillManifest = {
name,
title,
description,
tagline: opts.tagline,
category: opts.category,
tags: opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10),
price: parsedPrice,
tags,
price,
skillFile,
sourceUrl: opts.sourceUrl,
marketplaces: Object.fromEntries(MARKETPLACES.map(mp => [mp.id, { enabled: true, status: 'pending', command: 'command' in mp && mp.command ? mp.command({ name, title, description, tagline: opts.tagline, category: opts.category, tags: opts.tags.split(',').map(t => t.trim()).filter(Boolean).slice(0, 10), price: parsedPrice, skillFile, sourceUrl: opts.sourceUrl, marketplaces: {} }) : undefined, note: 'note' in mp ? mp.note : undefined }])) as SkillManifest['marketplaces'],
marketplaces: Object.fromEntries(MARKETPLACES.map(mp => [mp.id, { enabled: true, status: 'pending', command: 'command' in mp && mp.command ? mp.command({ name, title, description, tagline: opts.tagline, category: opts.category, tags, price, skillFile, sourceUrl: opts.sourceUrl, marketplaces: {} }) : undefined, note: 'note' in mp ? mp.note : undefined }])) as SkillManifest['marketplaces'],
};
await mkdir(dirname(resolve(opts.out)), { recursive: true });
await saveManifest(opts.out, manifest);
Expand Down
24 changes: 24 additions & 0 deletions packages/targets/pkg-flatpak/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,28 @@ describe('Flatpak manifest generation', () => {
appId: 'com.example.MyApp',
})).resolves.toEqual({ id: 'dry-run' });
});

it('rejects invalid app IDs before manifest generation', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-flatpak-'));
tempDirs.push(outDir);
const ctx = fakeBuildContext({
outDir,
projectDir: '/repo/myapp',
version: '1.2.3',
channel: 'stable',
}) as any;

for (const appId of ['../escape', 'com.example', 'com.example.App-', 'com.123.App']) {
await expect(adapter.build(ctx, { appId })).rejects.toThrow('appId');
}
});

it('rejects invalid app IDs before shipping', async () => {
await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
dryRun: true,
}) as any, {
appId: '../escape',
})).rejects.toThrow('appId');
});
});
24 changes: 2 additions & 22 deletions packages/targets/pkg-flatpak/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function renderList(values: string[], indent: string): string[] {
/**
* Validate a Flatpak application ID.
* Must be a reverse-DNS string with at least 3 dot-separated segments,
* each non-empty and containing only alphanumeric characters or hyphens/underscores.
* each starting with a letter and containing alphanumeric characters, hyphens, or underscores.
* Must not contain path traversal characters.
*/
function validateAppId(appId: string): void {
Expand All @@ -49,7 +49,7 @@ function validateAppId(appId: string): void {
if (!seg) {
throw new Error(`pkg-flatpak: invalid appId "${appId}" — segments must be non-empty`);
}
if (!/^[A-Za-z0-9_-]+$/.test(seg)) {
if (!/^[A-Za-z][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*$/.test(seg)) {
throw new Error(`pkg-flatpak: invalid appId "${appId}" — segment "${seg}" contains invalid characters`);
}
}
Expand Down Expand Up @@ -107,26 +107,6 @@ function renderFlatpakManifest(ctx: { projectDir: string; version: string; chann
return lines.join('\n');
}

/**
* Validate a Flatpak app ID (reverse-DNS format): at least 3 dot-separated segments,
* each containing alphanumeric or hyphens. e.g. "com.example.MyApp"
*/
function validateAppId(appId: string): void {
if (!appId) throw new Error('pkg-flatpak: appId is required');
const segments = appId.split('.');
if (segments.length < 3) {
throw new Error(`pkg-flatpak: invalid appId "${appId}". Must have at least 3 reverse-DNS segments (e.g. "com.example.MyApp").`);
}
if (appId.includes('..') || appId.includes('/') || appId.includes('\\')) {
throw new Error(`pkg-flatpak: appId "${appId}" contains path traversal characters.`);
}
for (const seg of segments) {
if (!seg || !/^[A-Za-z0-9_-]+$/.test(seg)) {
throw new Error(`pkg-flatpak: invalid segment "${seg}" in appId "${appId}".`);
}
}
}

export default defineTarget<Config>({
id: 'pkg-flatpak',
kind: 'package-manager',
Expand Down
44 changes: 43 additions & 1 deletion packages/targets/pkg-snap/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -60,4 +60,46 @@ describe('snapcraft manifest generation', () => {
snapName: 'myapp',
})).resolves.toEqual({ id: 'dry-run' });
});

it('rejects invalid snap names before generating manifests', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-snap-'));
tempDirs.push(outDir);

const ctx = fakeBuildContext({
outDir,
projectDir: '/repo/myapp',
version: '1.2.3',
channel: 'stable',
}) as any;

for (const snapName of ['Bad: Name', '-myapp', 'myapp-', 'my--app', '1234', 'a'.repeat(41)]) {
await expect(adapter.build(ctx, { snapName })).rejects.toThrow('snapName');
}
});

it('rejects invalid snap names before touching the output directory', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-snap-'));
tempDirs.push(outDir);
await mkdir(outDir, { recursive: true });

await expect(adapter.build(fakeBuildContext({
outDir,
projectDir: '/repo/myapp',
version: '1.2.3',
channel: 'stable',
}) as any, {
snapName: 'my--app',
})).rejects.toThrow('snapName');

await expect(readdir(outDir)).resolves.toEqual([]);
});

it('rejects invalid snap names before shipping', async () => {
await expect(adapter.ship(fakeShipContext({
version: '1.2.3',
dryRun: true,
}) as any, {
snapName: 'Bad: Name',
})).rejects.toThrow('snapName');
});
});
13 changes: 4 additions & 9 deletions packages/targets/pkg-snap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ function validateSnapName(snapName: string): void {
if (snapName.startsWith('-') || snapName.endsWith('-')) {
throw new Error(`pkg-snap: snapName "${snapName}" must not start or end with a hyphen`);
}
if (snapName.includes('--')) {
throw new Error(`pkg-snap: snapName "${snapName}" must not contain consecutive hyphens`);
}
if (!/^[a-z0-9-]+$/.test(snapName)) {
throw new Error(`pkg-snap: snapName "${snapName}" must contain only lowercase letters, digits, and hyphens`);
}
Expand All @@ -57,6 +60,7 @@ function validateSnapName(snapName: string): void {
}

function renderSnapcraftYaml(ctx: { projectDir: string; version: string; channel: string }, config: Config): string {
validateSnapName(config.snapName);
const grade = config.grade ?? (ctx.channel === 'stable' ? 'stable' : 'devel');
const confinement = config.confinement ?? 'strict';
const base = config.base ?? 'core22';
Expand Down Expand Up @@ -102,15 +106,6 @@ function renderSnapcraftYaml(ctx: { projectDir: string; version: string; channel
return lines.join('\n');
}

/** Validate a snap package name: lowercase, alphanumeric, hyphens only (no leading/trailing hyphen). */
function validateSnapName(name: string): void {
if (!name || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
throw new Error(
`pkg-snap: invalid snapName "${name}". Snap names must be lowercase alphanumeric with optional hyphens (no leading/trailing hyphen, no uppercase, no underscore).`,
);
}
}

export default defineTarget<Config>({
id: 'pkg-snap',
kind: 'package-manager',
Expand Down
36 changes: 36 additions & 0 deletions packages/targets/pkg-winget/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,40 @@ describe('winget manifest generation', () => {
],
})).resolves.toEqual({ id: 'dry-run' });
});

it('rejects invalid package IDs before manifest generation', async () => {
const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-winget-'));
tempDirs.push(outDir);
const ctx = fakeBuildContext({
outDir,
version: '1.2.3',
}) as any;
const installers = [
{
architecture: 'x64' as const,
url: 'https://downloads.example.com/my-tool-1.2.3-x64.exe',
sha256: 'c'.repeat(64),
},
];

for (const packageId of ['NoDot', '.Acme.MyTool', 'Acme..MyTool', 'Acme/MyTool']) {
await expect(adapter.build(ctx, { packageId, installers })).rejects.toThrow('packageId');
}
});

it('rejects invalid package IDs before shipping', 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),
},
],
})).rejects.toThrow('packageId');
});
});
22 changes: 0 additions & 22 deletions packages/targets/pkg-winget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,28 +124,6 @@ function renderLocaleManifest(config: Config, version: string): string {
return lines.join('\n');
}

/**
* Validate a winget package identifier: must contain at least one dot,
* each segment must be non-empty alphanumeric+hyphens/underscores/dots.
* e.g. "Microsoft.WindowsTerminal"
*/
function validatePackageId(packageId: string): void {
if (!packageId) throw new Error('pkg-winget: packageId is required');
const segments = packageId.split('.');
if (segments.length < 2) {
throw new Error(`pkg-winget: invalid packageId "${packageId}". Must be "Publisher.AppName" format with at least one dot.`);
}
for (const seg of segments) {
if (!seg) throw new Error(`pkg-winget: empty segment in packageId "${packageId}".`);
if (!/^[A-Za-z0-9_\-]+$/.test(seg)) {
throw new Error(`pkg-winget: invalid segment "${seg}" in packageId "${packageId}". Segments must be alphanumeric with hyphens or underscores.`);
}
}
if (packageId.includes('..') || packageId.includes('/') || packageId.includes('\\')) {
throw new Error(`pkg-winget: packageId "${packageId}" contains path traversal characters.`);
}
}

export default defineTarget<Config>({
id: 'pkg-winget',
kind: 'package-manager',
Expand Down
Loading