diff --git a/COPY.md b/COPY.md index cd2bd791..1e052a67 100644 --- a/COPY.md +++ b/COPY.md @@ -269,3 +269,16 @@ section above, then delete it from this list. is "Skilld is a curated registry of agent skills written by real people in the GitHub repos you already depend on." Both are true and they are not the same sentence. Decide which is canonical for a one-sentence slot, or record what each is for. + +### npm package Skills + +If the published package includes the Skill, show NPM first and select it by default. +Run is the second tab. Install follows it. Otherwise, keep Run as the default. +Offer pnpm and skills-npm setup within NPM. Highlight commands with rangi. +Use npm's official wordmark for NPM and pnpm's official colour symbol beside pnpm. +Show skills-npm as text. State each tool's minimum version and setup changes. +Explain that pnpm approval covers all package Skills and later package versions. +The CLI feature list links #234 as the required change until a supporting release has a version. +Describe ownership: doctor recognizes pnpm-managed Skills; pnpm controls their links and updates. +Selecting NPM hides the run block and unrelated setup links. Run and Install show their own content. +Install choices use text labels with Terminal, Claude, ChatGPT, and npm icons. diff --git a/app/assets/logos/README.md b/app/assets/logos/README.md new file mode 100644 index 00000000..9ba9eb02 --- /dev/null +++ b/app/assets/logos/README.md @@ -0,0 +1,6 @@ +# Package logos + +- `npm.svg`: [npm's official red wordmark](https://github.com/npm/logos/blob/master/npm%20logo/npm-logo-red.svg). +- `pnpm.svg` and `pnpm-light.svg`: [pnpm's official symbols](https://pnpm.io/logos). + +These files preserve the source artwork and colours. diff --git a/app/assets/logos/npm.svg b/app/assets/logos/npm.svg new file mode 100644 index 00000000..8e4aac5d --- /dev/null +++ b/app/assets/logos/npm.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/app/assets/logos/pnpm-light.svg b/app/assets/logos/pnpm-light.svg new file mode 100644 index 00000000..0286ac90 --- /dev/null +++ b/app/assets/logos/pnpm-light.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/logos/pnpm.svg b/app/assets/logos/pnpm.svg new file mode 100644 index 00000000..d60132a0 --- /dev/null +++ b/app/assets/logos/pnpm.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/components/CopyText.vue b/app/components/CopyText.vue index a439bbb0..d243b46d 100644 --- a/app/components/CopyText.vue +++ b/app/components/CopyText.vue @@ -1,14 +1,18 @@ diff --git a/layers/registry/app/components/_SkillPnpmPackage.vue b/layers/registry/app/components/_SkillPnpmPackage.vue new file mode 100644 index 00000000..983e9a5f --- /dev/null +++ b/layers/registry/app/components/_SkillPnpmPackage.vue @@ -0,0 +1,47 @@ + + + diff --git a/layers/registry/server/api/skill-package/[...slug].get.ts b/layers/registry/server/api/skill-package/[...slug].get.ts new file mode 100644 index 00000000..4188d986 --- /dev/null +++ b/layers/registry/server/api/skill-package/[...slug].get.ts @@ -0,0 +1,48 @@ +import { cached } from '#shared/server/cache' +import { defineApiHandler } from '#shared/server/handler' +import { presentPnpmPackage } from '../../presenters/pnpm-package' +import { pnpmPackageQuery, pnpmPackageSlug } from '../../schemas/pnpm-package' +import { detectPnpmPackage } from '../../utils/pnpm-package' +import { resolveRepoSourceIdentityFromRow } from '../../utils/repo-source-identity' +import { findSkillWithRow } from '../../utils/skills-registry' + +interface PackageSourceRow { + owner: string + repo: string + source_owner: string | null + source_repo: string | null + default_branch: string | null + rendered_skill_path: string | null + rendered_commit_sha: string | null + source_resolved: number | null +} + +export default defineApiHandler({ + schema: pnpmPackageQuery, + policy: [], + handler: async ({ event }) => { + const slug = pnpmPackageSlug.safeParse(getRouterParam(event, 'slug')) + if (!slug.success) + throw createError({ statusCode: 400, message: 'Use owner/repository/skill.' }) + return cached({ + storage: useStorage('edge-cache'), + // Cache the registry lookup too, so repeated views do not repeat D1 reads. + key: `skills:pnpm:v2:${slug.data}`, + ttlSeconds: 3600, + compute: async () => { + const found = await findSkillWithRow(event, slug.data, 's.rendered_commit_sha, s.source_resolved') + if (!found) + throw createError({ statusCode: 404, message: 'Skill not found.' }) + const { row } = found + if (!row.rendered_skill_path || row.source_resolved === 0) + return { _tag: 'Absent' } as const + const source = resolveRepoSourceIdentityFromRow({ owner: row.owner, repo: row.repo }, row) + const result = await detectPnpmPackage({ ...source, ref: row.rendered_commit_sha ?? row.default_branch ?? 'HEAD', skillPath: row.rendered_skill_path! }, fetch) + if (result._tag === 'Unavailable') + throw createError({ statusCode: 503, message: 'Package details are unavailable. Try again later.' }) + return result + }, + }) + }, + presenter: presentPnpmPackage, +}) diff --git a/layers/registry/server/presenters/pnpm-package.ts b/layers/registry/server/presenters/pnpm-package.ts new file mode 100644 index 00000000..8db9aba2 --- /dev/null +++ b/layers/registry/server/presenters/pnpm-package.ts @@ -0,0 +1,7 @@ +import type { PnpmPackage } from '../../shared/pnpm-package' + +export function presentPnpmPackage(result: PnpmPackage): PnpmPackage { + return result._tag === 'Found' + ? { _tag: 'Found', package: result.package, version: result.version, skill: result.skill } + : { _tag: result._tag } +} diff --git a/layers/registry/server/schemas/pnpm-package.ts b/layers/registry/server/schemas/pnpm-package.ts new file mode 100644 index 00000000..fbe310be --- /dev/null +++ b/layers/registry/server/schemas/pnpm-package.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const pnpmPackageQuery = z.object({}) +export const pnpmPackageSlug = z.string().max(512).regex(/^[\w.-]+\/[\w.-]+\/[^/\\]+$/).refine(value => !['.', '..'].includes(value.split('/')[2]!)) diff --git a/layers/registry/server/utils/pnpm-package.ts b/layers/registry/server/utils/pnpm-package.ts new file mode 100644 index 00000000..ab049746 --- /dev/null +++ b/layers/registry/server/utils/pnpm-package.ts @@ -0,0 +1,171 @@ +import type { PnpmPackage } from '../../shared/pnpm-package' +import { z } from 'zod' + +const packageName = z.string().max(214).regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/) +const manifestSchema = z.object({ name: packageName, private: z.boolean().optional() }) +const publishedSchema = z.object({ + name: packageName, + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + repository: z.union([z.string(), z.object({ url: z.string(), directory: z.string().optional() })]), + dist: z.object({ tarball: z.url() }), +}) +const ABSENT = { _tag: 'Absent' } as const +const UNAVAILABLE = { _tag: 'Unavailable' } as const +const MAX_COMPRESSED_BYTES = 8 * 1024 * 1024 +const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 + +interface Source { owner: string, repo: string, ref: string, skillPath: string } + +/** Inspect published bytes, rather than the AI-derived target_package classification. */ +export async function detectPnpmPackage(source: Source, fetcher: typeof fetch): Promise { + const match = /^(?:(.+)\/)?skills\/([^/]+)\/SKILL\.md$/.exec(source.skillPath) + if (!match || source.skillPath.split('/').some(part => part === '.' || part === '..' || part.includes('\\'))) + return ABSENT + const directory = match[1] ?? '' + // A nested Skill is not one of pnpm's immediate children of skills/. + if (directory.split('/').includes('skills')) + return ABSENT + const skill = match[2]! + const manifestPath = [directory, 'package.json'].filter(Boolean).join('/') + const manifestUrl = `https://raw.githubusercontent.com/${encodeURIComponent(source.owner)}/${encodeURIComponent(source.repo)}/${encodeURIComponent(source.ref)}/${manifestPath.split('/').map(encodeURIComponent).join('/')}` + const manifestRead = await readJson(manifestUrl, fetcher) + if (manifestRead._tag !== 'Read') + return manifestRead + const manifest = manifestSchema.safeParse(manifestRead.value) + if (!manifest.success || manifest.data.private) + return ABSENT + const publication = await readJson(`https://registry.npmjs.org/${encodeURIComponent(manifest.data.name)}/latest`, fetcher) + if (publication._tag !== 'Read') + return publication + const published = publishedSchema.safeParse(publication.value) + if (!published.success || published.data.name !== manifest.data.name) + return ABSENT + const repository = published.data.repository + const repositoryUrl = typeof repository === 'string' ? repository : repository.url + const identity = /^(?:git\+)?https:\/\/github\.com\/([^/]+)\/([^/#]+?)(?:\.git)?\/?$/.exec(repositoryUrl) + ?? /^(?:git\+)?(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/#]+?)(?:\.git)?$/.exec(repositoryUrl) + if (!identity || identity[1]!.toLowerCase() !== source.owner.toLowerCase() || identity[2]!.toLowerCase() !== source.repo.toLowerCase()) + return ABSENT + if (typeof repository !== 'string' && repository.directory !== undefined && repository.directory.replace(/^\.\//, '').replace(/\/$/, '') !== directory) + return ABSENT + const tarball = new URL(published.data.dist.tarball) + if (tarball.protocol !== 'https:' || tarball.hostname !== 'registry.npmjs.org' || tarball.port || tarball.username || tarball.password) + return ABSENT + const sent = await attempt(fetcher(tarball.href, { redirect: 'manual', signal: AbortSignal.timeout(8000) })) + if (sent._tag === 'Unavailable') + return UNAVAILABLE + const response = sent.value + if (!response.ok || !response.body) { + await response.body?.cancel() + return UNAVAILABLE + } + // Bound both network bytes and decompressed bytes before parsing any archive header. + const compressed = response.body.pipeThrough(byteLimit(MAX_COMPRESSED_BYTES)) + const gunzip = new DecompressionStream('gzip') + const archive = await attempt(readBounded(compressed.pipeThrough({ writable: gunzip.writable as WritableStream, readable: gunzip.readable }), MAX_ARCHIVE_BYTES)) + if (archive._tag === 'Unavailable') + return UNAVAILABLE + const bundled = containsSkill(archive.value, `package/skills/${skill}/SKILL.md`) + if (bundled === null) + return UNAVAILABLE + return bundled ? { _tag: 'Found', package: published.data.name, version: published.data.version, skill } : ABSENT +} + +type JsonRead = { _tag: 'Read', value: unknown } | typeof ABSENT | typeof UNAVAILABLE + +async function readJson(url: string, fetcher: typeof fetch): Promise { + const sent = await attempt(fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(4000) })) + if (sent._tag === 'Unavailable') + return UNAVAILABLE + const response = sent.value + // Transport failures remain retryable. They must never cache a negative package result. + if (response.status !== 404 && !response.ok) { + await response.body?.cancel() + return UNAVAILABLE + } + if (response.status === 404) { + await response.body?.cancel() + return ABSENT + } + if (!response.body) + return UNAVAILABLE + const bytes = await attempt(readBounded(response.body, 1024 * 1024)) + if (bytes._tag === 'Unavailable') + return UNAVAILABLE + const parsed = await attempt(Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes.value)) as unknown)) + return parsed._tag === 'Read' ? parsed : UNAVAILABLE +} + +function attempt(promise: Promise): Promise<{ _tag: 'Read', value: T } | typeof UNAVAILABLE> { + return promise.then(value => ({ _tag: 'Read' as const, value })).catch(() => { + // Expected transport, body, and parse failures become a retryable result, never an Absent result. + return UNAVAILABLE + }) +} + +function byteLimit(max: number): TransformStream { + let size = 0 + return new TransformStream({ + transform(chunk, controller) { + size += chunk.byteLength + if (size > max) + throw new Error('Package archive exceeds its byte limit.') + controller.enqueue(chunk) + }, + }) +} + +async function readBounded(stream: ReadableStream, max: number): Promise { + return new Uint8Array(await new Response(stream.pipeThrough(byteLimit(max))).arrayBuffer()) +} + +/** A regular nonempty file only. PAX and GNU path records apply to the next header. */ +function containsSkill(bytes: Uint8Array, target: string): boolean | null { + const decoder = new TextDecoder() + const text = (start: number, size: number) => decoder.decode(bytes.subarray(start, start + size)).split('\0')[0]! + let nextPath: string | undefined + let found = false + for (let offset = 0; offset + 512 <= bytes.length;) { + const header = bytes.subarray(offset, offset + 512) + if (header.every(byte => byte === 0)) + return found + const sizeText = text(offset + 124, 12).trim() + if (!/^[0-7]+$/.test(sizeText)) + return null + const size = Number.parseInt(sizeText, 8) + const start = offset + 512 + if (start + Math.ceil(size / 512) * 512 > bytes.length) + return null + const checksum = Number.parseInt(text(offset + 148, 8).trim(), 8) + const sum = header.reduce((total, byte, index) => total + (index >= 148 && index < 156 ? 32 : byte), 0) + if (checksum !== sum) + return null + const type = String.fromCharCode(header[156]!) + if (type === 'L') { + nextPath = text(start, size).replace(/\n$/, '') + } + else if (type === 'x') { + for (let position = start; position < start + size;) { + const space = bytes.indexOf(32, position) + if (space < position || space >= start + size) + return null + const length = Number(decoder.decode(bytes.subarray(position, space))) + if (!Number.isSafeInteger(length) || length <= space - position + 1 || position + length > start + size) + return null + const record = decoder.decode(bytes.subarray(space + 1, position + length - 1)) + if (record.startsWith('path=')) + nextPath = record.slice(5) + position += length + } + } + else if (type !== 'g') { + const prefix = text(offset + 345, 155) + const path = nextPath ?? [prefix, text(offset, 100)].filter(Boolean).join('/') + if (path === target) + found = (type === '0' || type === '\0') && size > 0 + nextPath = undefined + } + offset = start + Math.ceil(size / 512) * 512 + } + return null +} diff --git a/layers/registry/shared/pnpm-package.ts b/layers/registry/shared/pnpm-package.ts new file mode 100644 index 00000000..c1037904 --- /dev/null +++ b/layers/registry/shared/pnpm-package.ts @@ -0,0 +1,4 @@ +export type PnpmPackage + = | { _tag: 'Found', package: string, version: string, skill: string } + | { _tag: 'Absent' } + | { _tag: 'Unavailable' } diff --git a/test/unit/pnpm-package-panel.nuxt.test.ts b/test/unit/pnpm-package-panel.nuxt.test.ts new file mode 100644 index 00000000..32a49542 --- /dev/null +++ b/test/unit/pnpm-package-panel.nuxt.test.ts @@ -0,0 +1,59 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import SkillCommandPanel from '../../layers/registry/app/components/_SkillCommandPanel.vue' +import SkillPnpmPackage from '../../layers/registry/app/components/_SkillPnpmPackage.vue' + +const published = { _tag: 'Found', package: '@acme/kit', version: '1.2.3', skill: 'auth' } as const +const commands = { + runUrl: 'https://skilld.dev/gh/acme/kit/auth', + installCommand: 'npx skilld install acme/kit/auth', + runCopied: false, + installCopied: false, + copyError: '', + modelValue: 'run' as const, +} + +describe('npm package install option', () => { + it('offers npm only when a published package includes the Skill', async () => { + const wrapper = await mountSuspended(SkillCommandPanel, { props: { ...commands, layout: 'stacked' } }) + expect(wrapper.findAll('button').some(button => button.text() === 'NPM')).toBe(false) + await wrapper.setProps({ published }) + expect(wrapper.text()).toContain('pnpm add @acme/kit@1.2.3') + expect(wrapper.text()).not.toContain('npx skilld install acme/kit/auth') + expect(wrapper.text()).not.toContain('Run once off') + expect(wrapper.findAll('a').some(link => link.text() === 'Fork this Skill')).toBe(false) + await wrapper.setProps({ published: null }) + expect(wrapper.text()).toContain('Run once off') + expect(wrapper.text()).toContain('npx skilld install acme/kit/auth') + wrapper.unmount() + }) + + it('defaults to npm with Run second, then switches to Run and Install', async () => { + const wrapper = await mountSuspended(SkillCommandPanel, { props: { ...commands, published } }) + expect(wrapper.find('[data-testid="pnpm-package"]').exists()).toBe(true) + expect(wrapper.find('[aria-label="Command type"]').findAll('button').map(button => button.text())).toEqual(['NPM', 'Run', 'Install']) + await wrapper.findAll('button').find(button => button.text() === 'Run')!.trigger('click') + expect(wrapper.find('[data-testid="pnpm-package"]').exists()).toBe(false) + expect(wrapper.text()).toContain('Nothing lands on disk.') + await wrapper.findAll('button').find(button => button.text() === 'Install')!.trigger('click') + await wrapper.setProps({ modelValue: 'install' }) + + expect(wrapper.text()).toContain('npx skilld install acme/kit/auth') + expect(wrapper.find('[data-testid="pnpm-package"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('shows package approval and switches to skills-npm setup', async () => { + const wrapper = await mountSuspended(SkillPnpmPackage, { props: { published } }) + expect(wrapper.find('code').text()).toBe('pnpm add @acme/kit@1.2.3') + expect(wrapper.find('code span[style]').exists()).toBe(true) + expect(wrapper.text()).toContain('Approval covers every Skill and later version of the package.') + expect(wrapper.find('a').attributes('href')).toBe('https://www.npmjs.com/package/@acme/kit/v/1.2.3') + await wrapper.findAll('button').find(button => button.text() === 'skills-npm')!.trigger('click') + expect(wrapper.text()).toContain('npm install @acme/kit@1.2.3') + expect(wrapper.text()).toContain('npx skills-npm setup') + expect(wrapper.text()).toContain('Setup changes your prepare script') + expect(wrapper.text()).not.toContain('pnpm approve') + wrapper.unmount() + }) +}) diff --git a/test/unit/pnpm-package.test.ts b/test/unit/pnpm-package.test.ts new file mode 100644 index 00000000..d8a80e6d --- /dev/null +++ b/test/unit/pnpm-package.test.ts @@ -0,0 +1,73 @@ +import { pnpmPackageSlug } from '../../layers/registry/server/schemas/pnpm-package' +import { detectPnpmPackage } from '../../layers/registry/server/utils/pnpm-package' +import { tarGzFixture } from '../fixtures/tar-archive' + +const source = { owner: 'acme', repo: 'kit', ref: 'abc123', skillPath: 'packages/ui/skills/auth/SKILL.md' } +const manifest = { name: '@acme/ui', version: '1.0.0' } +const published = { + name: '@acme/ui', + version: '1.0.0', + repository: { url: 'git+https://github.com/acme/kit.git', directory: 'packages/ui' }, + dist: { tarball: 'https://registry.npmjs.org/@acme/ui/-/ui-1.0.0.tgz' }, +} + +it('accepts punctuation and Unicode in a Skill name', () => { + expect(pnpmPackageSlug.parse('acme/kit/c++-helpers')).toBe('acme/kit/c++-helpers') + expect(pnpmPackageSlug.parse('acme/kit/認証')).toBe('acme/kit/認証') +}) + +it('rejects traversal, extra segments, and oversized Skill slugs', () => { + for (const slug of ['acme/kit/..', 'acme/kit/.', 'acme/kit/a/b', 'acme/kit/a\\b', `acme/kit/${'a'.repeat(513)}`]) + expect(pnpmPackageSlug.safeParse(slug).success).toBe(false) +}) + +function reads(options: { manifest?: unknown, published?: unknown, path?: string, typeflag?: string, status?: number } = {}) { + const urls: string[] = [] + const fetcher: typeof fetch = async (input) => { + const url = String(input) + urls.push(url) + if (url.includes('raw.githubusercontent.com')) + return Response.json(options.manifest ?? manifest) + if (url.endsWith('/latest')) + return Response.json(options.published ?? published, { status: options.status ?? 200 }) + return new Response(tarGzFixture('package', [{ path: options.path ?? 'skills/auth/SKILL.md', typeflag: options.typeflag, bytes: new TextEncoder().encode('---\nname: auth\n---\nUse auth.') }])) + } + return { fetcher, urls } +} + +describe('published pnpm Skills', () => { + it('detects a scoped monorepo package from its published archive', async () => { + const { fetcher } = reads() + expect(await detectPnpmPackage(source, fetcher)).toEqual({ + _tag: 'Found', + package: '@acme/ui', + version: '1.0.0', + skill: 'auth', + }) + }) + + it.each([ + { manifest: { ...manifest, private: true } }, + { manifest: { name: 'bad;echo secret' } }, + { published: { ...published, repository: { url: 'https://github.com/other/kit' } } }, + { published: { ...published, name: '@other/ui' } }, + { published: { ...published, dist: { tarball: 'https://evil.test/archive.tgz' } } }, + { path: 'SKILL.md' }, + { path: 'skills/auth/nested/SKILL.md' }, + { typeflag: '2' }, + { status: 404 }, + ])('does not offer pnpm for unsupported publication %j', async (options) => { + expect(await detectPnpmPackage(source, reads(options).fetcher)).toEqual({ _tag: 'Absent' }) + }) + + it('does not read manifests for a root or nested Skill outside pnpm layout', async () => { + const { fetcher, urls } = reads() + expect(await detectPnpmPackage({ ...source, skillPath: 'SKILL.md' }, fetcher)).toEqual({ _tag: 'Absent' }) + expect(await detectPnpmPackage({ ...source, skillPath: 'skills/group/auth/SKILL.md' }, fetcher)).toEqual({ _tag: 'Absent' }) + expect(urls).toEqual([]) + }) + + it('keeps upstream outages distinct from a package without Skills', async () => { + expect(await detectPnpmPackage(source, reads({ status: 503 }).fetcher)).toEqual({ _tag: 'Unavailable' }) + }) +})