diff --git a/.changeset/npm-account-page.md b/.changeset/npm-account-page.md new file mode 100644 index 0000000..7995883 --- /dev/null +++ b/.changeset/npm-account-page.md @@ -0,0 +1,9 @@ +--- +'@pdsjs/core': patch +--- + +The account page grows a Packages section when the npm registry is on: the +hosted packages with their dist-tags, versions, sizes and copyable install +commands, served by `GET /account/api/npm/packages` from the +dev.pdsjs.npm.package records. The rail counts the packages the way it +counts container images. diff --git a/packages/account-ui/src/app.jsx b/packages/account-ui/src/app.jsx index b373c87..3148c0d 100644 --- a/packages/account-ui/src/app.jsx +++ b/packages/account-ui/src/app.jsx @@ -19,6 +19,7 @@ import { GitRepoPage } from '#/pages/git-repo-page.jsx'; import { HistoryPage } from '#/pages/history.jsx'; import { HomePage } from '#/pages/home.jsx'; import { IdentityPage } from '#/pages/identity.jsx'; +import { PackagesPage } from '#/pages/packages.jsx'; import { RecordsPage } from '#/pages/records.jsx'; import { RegistryPage } from '#/pages/registry.jsx'; import { SecurityPage } from '#/pages/security.jsx'; @@ -32,6 +33,7 @@ const SITES_PATH = '/account/sites'; const GIT_PATH = '/account/git'; const DRIVE_PATH = '/account/drive'; const REGISTRY_PATH = '/account/registry'; +const PACKAGES_PATH = '/account/packages'; /** The account sections, in rail order. Counts come from the overview. * Sites appears only when the server composes the feature in, under a Labs @@ -51,7 +53,8 @@ function sectionsFor(data) { ...(data.features.sites || data.features.git || data.features.drive || - data.features.oci + data.features.oci || + data.features.npm ? [{ heading: 'Labs' }] : []), ...(data.features.sites ? [{ path: SITES_PATH, title: 'Sites' }] : []), @@ -82,6 +85,15 @@ function sectionsFor(data) { }, ] : []), + ...(data.features.npm + ? [ + { + path: PACKAGES_PATH, + title: 'Packages', + count: data.stats.npmPackages, + }, + ] + : []), ]; } @@ -114,6 +126,10 @@ const PAGES = { title: 'Registry', sub: 'A container registry in your repo. Push with docker, pull from anywhere.', }, + [PACKAGES_PATH]: { + title: 'Packages', + sub: 'An npm registry in your repo. Publish with npm, install from anywhere.', + }, '/account/history': { title: 'History', sub: 'Changes recorded by this server, newest first.', @@ -290,6 +306,9 @@ export function App() { } else if (path === REGISTRY_PATH) { page = PAGES[REGISTRY_PATH]; body = ; + } else if (path === PACKAGES_PATH) { + page = PAGES[PACKAGES_PATH]; + body = ; } else if (path === DRIVE_PATH) { page = PAGES[DRIVE_PATH]; body = ( diff --git a/packages/account-ui/src/lib/query.js b/packages/account-ui/src/lib/query.js index 6f033ac..44504d9 100644 --- a/packages/account-ui/src/lib/query.js +++ b/packages/account-ui/src/lib/query.js @@ -26,6 +26,7 @@ export const queryKeys = { siteFiles: (/** @type {string} */ name) => ['site-files', name], gitRepos: ['git-repos'], ociRepos: ['oci-repos'], + npmPackages: ['npm-packages'], driveFiles: (/** @type {{folder: string, space: string}} */ params) => [ 'drive-files', params, diff --git a/packages/account-ui/src/pages/packages.jsx b/packages/account-ui/src/pages/packages.jsx new file mode 100644 index 0000000..cbc2b7f --- /dev/null +++ b/packages/account-ui/src/pages/packages.jsx @@ -0,0 +1,181 @@ +import { useQuery } from '@tanstack/react-query'; +import { CheckIcon, CopyIcon, PackageIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '#/components/atoms/badge.jsx'; +import { Notice } from '#/components/atoms/notice.jsx'; +import { RowIcon } from '#/components/atoms/row-icon.jsx'; +import { EmptyState } from '#/components/molecules/empty-state.jsx'; +import { ListRow } from '#/components/molecules/list-row.jsx'; +import { TextLink } from '#/components/molecules/text-link.jsx'; +import { get } from '#/lib/api.js'; +import { bytes, timeAgo } from '#/lib/format.js'; +import { queryKeys } from '#/lib/query.js'; + +/** One line summarizing a package's shape. */ +function packageSummary(pkg) { + if (pkg.versions.length === 0) return 'nothing published yet'; + const parts = [ + `${pkg.versions.length} ${pkg.versions.length === 1 ? 'version' : 'versions'}`, + ]; + parts.push(bytes(pkg.size)); + if (pkg.updatedAt) parts.push(`published ${timeAgo(pkg.updatedAt)}`); + return parts.join(' · '); +} + +/** A command with a copy button beside it, shown in full where room allows. */ +function CopyCommand({ command, compact }) { + const [copied, setCopied] = useState(false); + + const copy = (event) => { + event.preventDefault(); + event.stopPropagation(); + navigator.clipboard.writeText(command).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + ); +} + +/** Every version of one package: number, size, when, its install command. */ +function VersionList({ pkg, registryUrl }) { + const rows = [...pkg.versions].reverse(); + return ( +
+ {rows.map((entry) => ( +
+ {entry.version} + + {bytes(entry.size)} + {entry.publishedAt + ? ` · published ${timeAgo(entry.publishedAt)}` + : ''} + + +
+ ))} +
+ ); +} + +/** + * @param {{data: Object}} props + */ +export function PackagesPage({ data: account }) { + const [openVersions, setOpenVersions] = useState( + /** @type {string|null} */ (null), + ); + const { data, error } = useQuery({ + queryKey: queryKeys.npmPackages, + queryFn: () => get('/npm/packages'), + }); + + if (error) { + return {error.message}; + } + if (!data) return null; + + if (!data.packages.length) { + return ( + } + title="No packages yet" + description={ + <> + Your account is an npm registry. Point npm at{' '} + + https://{account.hostname}/npm/ + + , sign in with{' '} + npm login using an + app password, and{' '} + npm publish. Tarballs + are stored in your repo as blobs, so packages ride along in backups + and migrate with your account. Anyone can install. + + } + /> + ); + } + + return ( +
+ {data.packages.map((pkg) => { + const latest = pkg.distTags.find((t) => t.tag === 'latest'); + const otherTags = pkg.distTags.filter((t) => t.tag !== 'latest'); + return ( +
+ + + + } + title={ +
+ + {pkg.name} + + {latest && ( + + v{latest.version} + + )} + {otherTags.slice(0, 3).map((tag) => ( + + {tag.tag} + + ))} +
+ } + description={packageSummary(pkg)} + actions={ +
+ {pkg.versions.length > 0 && ( + + setOpenVersions( + openVersions === pkg.name ? null : pkg.name, + ) + } + > + {openVersions === pkg.name ? 'Hide versions' : 'Versions'} + + )} + +
+ } + /> + {openVersions === pkg.name && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/packages/cloudflare/src/index.js b/packages/cloudflare/src/index.js index c81eda8..0ee296e 100644 --- a/packages/cloudflare/src/index.js +++ b/packages/cloudflare/src/index.js @@ -1339,6 +1339,7 @@ export class PDSDurableObject { siteInstaller, gitHttpEnabled: env.PDS_EXPERIMENTAL_GIT_HTTP === 'true', ociEnabled: env.PDS_EXPERIMENTAL_OCI === 'true', + npmEnabled: env.PDS_EXPERIMENTAL_NPM === 'true', // The closure reads this.pds only at request time, after construction. gitBrowser: env.PDS_EXPERIMENTAL_GIT_BROWSE !== 'false' diff --git a/packages/core/src/handlers/account.js b/packages/core/src/handlers/account.js index 78b871a..ee9ee31 100644 --- a/packages/core/src/handlers/account.js +++ b/packages/core/src/handlers/account.js @@ -233,6 +233,7 @@ function updateAvailable(current, candidate) { * @property {import('../ports.js').SiteInstallerPort|null} siteInstaller * @property {boolean} [gitHttpEnabled] - Whether the platform serves read-only git smart HTTP under /git/, so the repositories page can show plain-git clone URLs * @property {boolean} [ociEnabled] - Whether the platform serves the OCI registry under /v2/, so the account page can show the hosted images + * @property {boolean} [npmEnabled] - Whether the platform serves the npm registry under /npm/, so the account page can show the hosted packages * @property {{listTree: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>, listCommits: (did: string, repo: string, ref?: string, limit?: number, space?: string) => Promise}|null} [gitBrowser] - Read-only file browsing of hosted git repositories, built by the platform from the git package. Absent, the tree endpoints report the feature unavailable. * @property {import('../ports.js').DriveBrowserPort|null} [driveBrowser] - Read-only view of the files stored in the repo, built by the platform from the drive package. Absent, the drive endpoints report the feature unavailable. * @property {import('../ports.js').DriveWriterPort|null} [driveWriter] - Writes one stored file, built by the same package. Absent, the upload endpoint reports the feature unavailable. @@ -392,6 +393,7 @@ export function createAccountHandlers(ctx) { siteInstaller, gitHttpEnabled, ociEnabled, + npmEnabled, gitBrowser, driveBrowser, driveWriter, @@ -1131,6 +1133,7 @@ export function createAccountHandlers(ctx) { git: true, gitBrowse: Boolean(gitBrowser), oci: Boolean(ociEnabled), + npm: Boolean(npmEnabled), drive: Boolean(driveBrowser), driveUpload: Boolean(driveWriter), spaceAdmin: Boolean(spaceAdmin), @@ -1141,6 +1144,9 @@ export function createAccountHandlers(ctx) { ociRepos: collections.find((c) => c.name === 'dev.pdsjs.oci.repo')?.count ?? 0, + npmPackages: + collections.find((c) => c.name === 'dev.pdsjs.npm.package') + ?.count ?? 0, driveFiles: driveFileCount, blobs: blobs.count, blobsTruncated: blobs.truncated, @@ -2130,6 +2136,82 @@ export function createAccountHandlers(ctx) { }); } + /** + * GET /account/api/npm/packages - npm packages hosted in this account: + * dev.pdsjs.npm.package records summarized for the packages page. The + * registry stores sizes on the blob references, so the summary reads the + * record alone. + * @param {Request} request @param {URL} url + */ + async function handleApiNpmPackages(request, url) { + return accountApi(request, url, async () => { + if (!npmEnabled) return { enabled: false, packages: [] }; + const page = await actorStorage.listRecords( + 'dev.pdsjs.npm.package', + null, + 100, + ); + const packages = []; + for (const record of page.records) { + const value = + /** @type {{name?: unknown, distTags?: unknown, versions?: unknown, createdAt?: unknown, updatedAt?: unknown}} */ ( + toDisplayValue( + cborDecode( + record.value instanceof Uint8Array + ? record.value + : new Uint8Array(record.value), + ), + ) + ); + const name = + typeof value.name === 'string' + ? value.name + : record.uri.split('/').pop() || ''; + const distTags = + /** @type {Array<{tag?: unknown, version?: unknown}>} */ ( + Array.isArray(value.distTags) ? value.distTags : [] + ).filter( + (t) => typeof t.tag === 'string' && typeof t.version === 'string', + ); + const versions = + /** @type {Array<{version?: unknown, publishedAt?: unknown, tarball?: {size?: unknown}, manifest?: {size?: unknown}}>} */ ( + Array.isArray(value.versions) ? value.versions : [] + ).filter((v) => typeof v.version === 'string'); + let size = 0; + for (const entry of versions) { + if (typeof entry.tarball?.size === 'number') { + size += entry.tarball.size; + } + if (typeof entry.manifest?.size === 'number') { + size += entry.manifest.size; + } + } + packages.push({ + name, + uri: record.uri, + distTags, + versions: versions.map((entry) => ({ + version: entry.version, + size: + typeof entry.tarball?.size === 'number' ? entry.tarball.size : 0, + publishedAt: + typeof entry.publishedAt === 'string' ? entry.publishedAt : null, + })), + size, + createdAt: + typeof value.createdAt === 'string' ? value.createdAt : null, + updatedAt: + typeof value.updatedAt === 'string' ? value.updatedAt : null, + }); + } + return { + enabled: true, + registryUrl: `${url.protocol}//${url.host}/npm/`, + packages, + }; + }); + } + /** * POST /account/api/git/create - Create an empty repository, the way the * hosted forges do: the record exists at once with no refs and no bundles, @@ -5051,6 +5133,10 @@ export function createAccountHandlers(ctx) { method: 'GET', handler: handleApiOciRepos, }, + '/account/api/npm/packages': { + method: 'GET', + handler: handleApiNpmPackages, + }, '/account/api/git/create': { method: 'POST', handler: handleApiGitCreate, diff --git a/packages/core/src/pds.js b/packages/core/src/pds.js index b29098b..7b01b20 100644 --- a/packages/core/src/pds.js +++ b/packages/core/src/pds.js @@ -140,6 +140,7 @@ export class PersonalDataServer { * @param {import('./ports.js').SiteInstallerPort} [config.siteInstaller] - Server-side app installs for the account page, supplied the same way. Absent, the install endpoints report the feature unavailable. * @param {boolean} [config.gitHttpEnabled] - Whether the platform serves read-only git smart HTTP under /git/, so the repositories page can show plain-git clone URLs. * @param {boolean} [config.ociEnabled] - Whether the platform serves the OCI registry under /v2/, so the account page can show the hosted images. + * @param {boolean} [config.npmEnabled] - Whether the platform serves the npm registry under /npm/, so the account page can show the hosted packages. * @param {{listTree: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>, listCommits: (did: string, repo: string, ref?: string, limit?: number, space?: string) => Promise}} [config.gitBrowser] - Read-only file browsing of hosted git repositories for the account page, built by the platform from the git package. Absent, the tree endpoints report the feature unavailable. * @param {import('./ports.js').DriveBrowserPort} [config.driveBrowser] - Read-only view of the files stored in the repo, built by the platform from the drive package. Absent, the drive endpoints report the feature unavailable and the page hides the section. * @param {import('./ports.js').DriveWriterPort} [config.driveWriter] - Writes one stored file, built by the same package. Absent, the drive page lists and downloads but cannot upload. @@ -179,6 +180,7 @@ export class PersonalDataServer { siteInstaller, gitHttpEnabled, ociEnabled, + npmEnabled, gitBrowser, driveBrowser, driveWriter, @@ -418,6 +420,7 @@ export class PersonalDataServer { siteInstaller: siteInstaller || null, gitHttpEnabled: Boolean(gitHttpEnabled), ociEnabled: Boolean(ociEnabled), + npmEnabled: Boolean(npmEnabled), gitBrowser: gitBrowser || null, driveBrowser: this.driveBrowser, driveWriter: this.driveWriter, diff --git a/packages/node/src/index.js b/packages/node/src/index.js index c260ca1..43e4297 100644 --- a/packages/node/src/index.js +++ b/packages/node/src/index.js @@ -733,6 +733,7 @@ export async function createServer({ siteInstaller, gitHttpEnabled: gitHttp, ociEnabled: oci, + npmEnabled: npm, gitBrowser, driveBrowser, driveWriter, diff --git a/packages/npm/test/npm-cli.test.js b/packages/npm/test/npm-cli.test.js index f0caf0c..29d3f03 100644 --- a/packages/npm/test/npm-cli.test.js +++ b/packages/npm/test/npm-cli.test.js @@ -323,6 +323,47 @@ describe('npm registry e2e', () => { expect(missing.status).toBe(404); }); + it('summarizes hosted packages for the account page', async () => { + const signIn = await fetch(`${BASE}/account/sign-in`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ username: DID, password: PASSWORD }), + redirect: 'manual', + }); + const cookie = (signIn.headers.get('set-cookie') || '').split(';')[0]; + expect(cookie).toContain('pdsjs_account='); + + const overview = await fetch(`${BASE}/account/api/overview`, { + headers: { Cookie: cookie }, + }); + const account = await overview.json(); + expect(account.features.npm).toBe(true); + expect(account.stats.npmPackages).toBeGreaterThanOrEqual(2); + + const res = await fetch(`${BASE}/account/api/npm/packages`, { + headers: { Cookie: cookie }, + }); + expect(res.ok).toBe(true); + const body = await res.json(); + expect(body.enabled).toBe(true); + expect(body.registryUrl).toBe(`${BASE}/npm/`); + const demo = body.packages.find( + (/** @type {{name: string}} */ p) => p.name === 'demo-pkg', + ); + expect( + demo.versions.map((/** @type {{version: string}} */ v) => v.version), + ).toEqual(['1.0.0', '1.1.0']); + expect(demo.distTags).toContainEqual({ tag: 'latest', version: '1.0.0' }); + expect(demo.size).toBeGreaterThan(0); + const scoped = body.packages.find( + (/** @type {{name: string}} */ p) => p.name === '@chad/util', + ); + expect(scoped.versions).toHaveLength(1); + + const anonymous = await fetch(`${BASE}/account/api/npm/packages`); + expect(anonymous.status).toBe(401); + }); + it('refuses a publish whose body lies about the tarball', async () => { const manifest = { name: 'liar',