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