Huakun Shen 0cc744592f
Feature: add author, size, readme display for extension store page (#74)
* fix: update email field in KunkunExtManifest to be optional and nullable

* refactor: update Supabase type generation command and enhance database types structure

- Changed the Supabase type generation command to include a specific project reference and output path.
- Refactored the database types in `database.types.ts` for improved readability and added new fields, including `extension_state` and `package_json` in the `ext_publish` table.
- Ensured consistent formatting across type definitions for better maintainability.

* feat: add optional README path to ExtPackageJson and enhance tests for README retrieval

* feat: add optional readmeContent to ExtensionPublishValidationData and retrieve README in validateJsrPackageAsKunkunExtension

* feat: add optional readme field to database types for improved package metadata

* feat: enhance StoreExtDetail to display package metadata including author and contributors

- Added packageJson prop to StoreExtDetail for improved extension metadata display.
- Implemented rendering of author and contributors from packageJson.
- Integrated README content display in StoreExtDetail if available.
- Updated +page.svelte to parse and provide packageJson data using valibot for validation.

* feat: enhance TauriLink component to support conditional rendering based on Tauri environment

- Added detection for Tauri environment using the browser variable.
- Updated the TauriLink component to render a button when in Tauri, and an anchor tag for external links otherwise.
- Improved user experience by ensuring appropriate link behavior based on the application context.

* feat: add unpacked size to npm registry

* feat: replace size in ext_publish table to tarball_size, add unpacked_size (only applicable to npm)

* feat: add pretty-bytes dependency and update debug package version

- Added `pretty-bytes` package with version 6.1.1 to `package.json`.
- Updated `debug` package to use `supports-color@9.4.0` in `pnpm-lock.yaml` for improved compatibility.

* feat: add tarball_size field to database types for improved package metadata

* feat: add readme fetching for npm registry, readme from github

* fix: remove console.log from NPM API test to clean up output

* style: update extension store details

* style: update README section in StoreExtDetail component for improved styling

* fix: update command input placeholder text in English, Russian, and Chinese translations for clarity

* chore: bump version to 0.1.18 in package.json

* fix: lint
2025-01-23 07:07:29 -05:00

261 lines
8.7 KiB
TypeScript

import { ExtPackageJson, License } from "@kksh/api/models"
import * as v from "valibot"
import {
authenticatedUserIsMemberOfGitHubOrg,
parseGitHubRepoFromUri,
userIsPublicMemberOfGitHubOrg
} from "../github"
import type { ExtensionPublishValidationData } from "../models"
import { getInfoFromRekorLog } from "../sigstore"
import { getRawFileFromGitHub, getTarballSize } from "../utils"
import {
NpmPkgMetadata,
NpmPkgVersionMetadata,
NpmSearchResultObject,
NpmSearchResults,
Provenance
} from "./models"
export * from "./models"
/**
* Get the full metadata of an npm package
* @param pkgName
* @returns
*/
export function getFullNpmPackageInfo(pkgName: string): Promise<NpmPkgMetadata | null> {
return fetch(`https://registry.npmjs.org/${pkgName}`).then((res) => (res.ok ? res.json() : null))
}
/**
* Fetch the package.json data of an npm package
* @param pkgName
* @param version
* @returns
*/
export function getNpmPackageInfoByVersion(
pkgName: string,
version: string
): Promise<NpmPkgVersionMetadata | null> {
return fetch(`https://registry.npmjs.org/${pkgName}/${version}`).then((res) =>
res.ok ? res.json() : null
)
}
/**
* Get the provenance of an npm package
* If a package has no provenance, return null
* @param pkgName
* @param version
* @returns
*/
export function getNpmPkgProvenance(pkgName: string, version: string): Promise<Provenance | null> {
return fetch(`https://www.npmjs.com/package/${pkgName}/v/${version}/provenance`)
.then((res) => res.json())
.catch((err) => null)
}
/**
* List all packages under a scope
* @example
* To get package names under a scope, you can do:
* ```ts
* (await listPackagesOfMaintainer("huakunshen")).map((pkg) => pkg.package.name)
* ```
* @param username npm organization or username
* @returns
*/
export function listPackagesOfMaintainer(username: string): Promise<NpmSearchResultObject[]> {
return fetch(`https://registry.npmjs.org/-/v1/search?text=maintainer:${username}&size=250`, {
headers: {
"sec-fetch-dest": "document"
}
})
.then((res) => res.json())
.then((res) => v.parse(NpmSearchResults, res).objects)
}
export function listPackagesOfScope(scope: string): Promise<NpmSearchResultObject[]> {
return fetch(`https://registry.npmjs.org/-/v1/search?text=${scope}&size=250`, {
headers: {
"sec-fetch-dest": "document"
}
})
.then((res) => res.json())
.then((res) => v.parse(NpmSearchResults, res).objects)
}
export function getNpmPackageTarballUrl(
pkgName: string,
version: string
): Promise<string | undefined> {
return getNpmPackageInfoByVersion(pkgName, version).then((res) => res?.dist?.tarball)
}
export function npmPackageExists(pkgName: string, version: string): Promise<boolean> {
return getNpmPackageInfoByVersion(pkgName, version).then((res) => res !== null)
}
/**
* @param url Sample URL: https://search.sigstore.dev/?logIndex=153252145
* @returns
*/
function parseLogIdFromSigstoreSearchUrl(url: string): string {
const urlObj = new URL(url)
const logIndex = urlObj.searchParams.get("logIndex")
if (!logIndex) {
throw new Error("Could not parse log index from sigstore search url")
}
return logIndex
}
export async function validateNpmPackageAsKunkunExtension(payload: {
pkgName: string
version: string
githubUsername: string
tarballSizeLimit?: number
githubToken?: string
provenance?: Provenance // provenance API has cors policy, when we run this validation on client side, a provenance should be passed in
}): Promise<{
error?: string
data?: ExtensionPublishValidationData
}> {
/* -------------------------------------------------------------------------- */
/* check if npm package exist */
/* -------------------------------------------------------------------------- */
const pkgExists = await npmPackageExists(payload.pkgName, payload.version)
if (!pkgExists) {
return { error: "Package does not exist" }
}
if (!pkgExists) {
return { error: "NPM package does not exist" }
}
/* -------------------------------------------------------------------------- */
/* check if npm package has provenance */
/* -------------------------------------------------------------------------- */
const provenance =
payload.provenance ?? (await getNpmPkgProvenance(payload.pkgName, payload.version))
if (!provenance) {
return {
error: "Package doesn't have provenance, not signed by github action"
}
}
if (provenance.sourceCommitUnreachable) {
return { error: "Package's source commit is unreachable" }
}
if (provenance.sourceCommitNotFound) {
return { error: "Package's source commit is not found" }
}
/* -------------------------------------------------------------------------- */
/* get rekor sigstore */
/* -------------------------------------------------------------------------- */
if (!provenance?.summary.transparencyLogUri) {
return { error: "Package's rekor log is not found" }
}
const logIndex = parseLogIdFromSigstoreSearchUrl(provenance.summary.transparencyLogUri)
const rekorGit = await getInfoFromRekorLog(logIndex)
if (rekorGit.commit !== provenance.summary.sourceRepositoryDigest) {
return { error: "Package's rekor log commit is not the same as the source commit" }
}
/* -------------------------------------------------------------------------- */
/* check if npm pkg is linked to github repo */
/* -------------------------------------------------------------------------- */
const repoUri = provenance.summary.sourceRepositoryUri
const githubRepo = parseGitHubRepoFromUri(repoUri)
/* -------------------------------------------------------------------------- */
/* Verify Repo Ownership */
/* -------------------------------------------------------------------------- */
if (githubRepo.owner !== payload.githubUsername) {
const isPublicMemeber = await userIsPublicMemberOfGitHubOrg(
githubRepo.owner,
payload.githubUsername
)
let isOrgMember = false
if (payload.githubToken) {
isOrgMember = await authenticatedUserIsMemberOfGitHubOrg(
githubRepo.owner,
payload.githubToken
)
}
if (!isPublicMemeber && !isOrgMember) {
return {
error: `You (${payload.githubUsername}) are not authorized to publish this package. Only ${githubRepo.owner} or its organization members can publish it.`
}
}
}
/* -------------------------------------------------------------------------- */
/* validate package.json format against latest schema */
/* -------------------------------------------------------------------------- */
const packageJson = await getNpmPackageInfoByVersion(payload.pkgName, payload.version)
if (!packageJson) {
return { error: "Could not find package.json in NPM package" }
}
if (!packageJson.license) {
return { error: "Package license field is not found" }
}
const licenseParsed = v.safeParse(License, packageJson.license)
if (!licenseParsed.success) {
return { error: `Package license field ${packageJson.license} is not valid` }
}
const parseResult = v.safeParse(ExtPackageJson, packageJson)
if (!parseResult.success) {
console.error(v.flatten(parseResult.issues))
return { error: `package.json format not valid` }
}
/* -------------------------------------------------------------------------- */
/* get more package info */
/* -------------------------------------------------------------------------- */
const tarballUrl = packageJson.dist?.tarball
if (!tarballUrl) {
return { error: "Could not get tarball URL for NPM package" }
}
const shasum = packageJson.dist?.shasum
if (!shasum) {
return { error: "Could not get shasum for NPM package" }
}
const apiVersion = parseResult.output.dependencies?.["@kksh/api"]
if (!apiVersion) {
return {
error: `Extension ${parseResult.output.kunkun.identifier} doesn't not have @kksh/api as a dependency`
}
}
const tarballSize = await getTarballSize(tarballUrl, "GET") // NPM HEAD request doesn't support content-length
const readmeContent = await getRawFileFromGitHub(
githubRepo.owner,
githubRepo.repo,
provenance.summary.sourceRepositoryDigest,
parseResult.output.readme ?? "README.md"
)
return {
data: {
pkgJson: parseResult.output,
license: licenseParsed.output,
readmeContent,
tarballUrl,
shasum,
apiVersion,
tarballSize,
unpackedSize: packageJson.dist?.unpackedSize,
rekorLogIndex: logIndex,
github: {
githubActionInvocationId: rekorGit.githubActionInvocationId,
commit: provenance.summary.sourceRepositoryDigest,
repo: githubRepo.repo,
owner: githubRepo.owner,
workflowPath: rekorGit.workflowPath
}
}
}
}