diff --git a/CLAUDE.md b/CLAUDE.md index d6761b6..41a59b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ Search algorithm: `./docs/search-algorithm.md` **CLI `skillx use` resolution**: `author/skill` (two-part → DB slug `author-skill`) | `org/repo/skill` (three-part → DB slug `org-skill`, fallback register from GitHub) | `slug` (direct lookup, fallback search) | `"keywords"` (search mode). Flags: `--include-refs`, `--include-scripts` for extended content. Display logic split into `use-display.ts` module. -**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx import owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth (`skillx publish owner/repo` is a deprecated alias). Releases: `skillx validate|pack|publish |promote` (`skillx release pack|publish` are aliases), JSON envelope via `--json`. +**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx import owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth (`skillx publish owner/repo` is a deprecated alias). Releases: `skillx validate|pack|publish |promote|visibility get|set` (`skillx release pack|publish` are aliases), JSON envelope via `--json`. **Styling**: Always dark theme. Use `bg-slate-900`, `text-white`, `text-mint`, `border-mint/20`. Geist Sans/Mono fonts. Lucide icons. diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts index dd84a27..fe6c9d7 100644 --- a/apps/web/app/lib/releases/ingest-release.ts +++ b/apps/web/app/lib/releases/ingest-release.ts @@ -47,10 +47,13 @@ type Kit = { bucket: R2Bucket; provenance: ReleaseProvenance | null }; */ function assertVisibility(identity: string, requested: PackageVisibility | null, stored: string): void { if (requested === null || requested === stored) return; + // Widening (out of private, or to public) needs a signed-in approval, so only the dashboard can do it. + const widening = stored === "private" || requested === "public"; + const where = widening ? "in the dashboard (/dashboard)" : "with `skillx visibility set` or in the dashboard (/dashboard)"; throw releaseError( 409, "release.visibility_mismatch", - `${identity} is ${stored}; publishing a release does not change the visibility of an existing package (requested ${requested})`, + `${identity} is ${stored}; publishing a release does not change the visibility of an existing package (requested ${requested}). Change it first ${where}, or publish without a visibility`, ); } diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts index f19605b..33efc2b 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -184,6 +184,7 @@ export async function readReleaseList(ctx: ReadContext, params: Params): Promise return Response.json( { identity: list.identity, + visibility: list.visibility, releases: list.releases.map((release) => ({ version: release.version, channel: release.channel, diff --git a/apps/web/tests/creator/cli-visibility-roundtrip.test.ts b/apps/web/tests/creator/cli-visibility-roundtrip.test.ts new file mode 100644 index 0000000..a88f668 --- /dev/null +++ b/apps/web/tests/creator/cli-visibility-roundtrip.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { releasePublishScope } from "~/lib/auth/api-key-scopes"; +import { servePackageAction } from "~/lib/creator/owner-actions-http"; +import { loader as listLoader } from "~/routes/api.v1.package-releases"; +import { runVisibilityGet, runVisibilitySet } from "../../../../packages/cli/src/commands/visibility"; +import { envOf, publish, useStateHarness } from "../releases/release-state-helpers"; +import type { Harness } from "../releases/release-state-helpers"; +import { ORIGIN, packageAudit, visibilityOf } from "./creator-helpers"; + +/** + * `skillx visibility get|set` against the real registry handlers: the CLI's + * fetch is routed in-process to the release list loader and the owner + * visibility action, so the request shape, the compare-and-set and the + * refusals are the registry's own. `user-a` owns `@alpha`. + */ +vi.mock("~/lib/auth/session-helpers", () => ({ getSession: async () => null })); + +let h: Harness; +const REG = { registry: ORIGIN }; + +/** Routes the CLI's `fetch` to the registry handlers under test. */ +function routeFetchToRegistry() { + vi.stubGlobal("fetch", async (input: string, init: RequestInit = {}) => { + const request = new Request(input, init); + const path = new URL(request.url).pathname; + const match = /^\/api\/v1\/packages\/(@[^/]+)\/([^/]+)\/(releases|visibility)$/.exec(path); + if (match === null) return new Response("not routed", { status: 599 }); + const params = { scope: match[1]!, slug: match[2]! }; + if (match[3] === "releases") return listLoader({ request, params, context: { cloudflare: { env: envOf() } } } as never); + return servePackageAction(request, envOf() as never, params, "visibility"); + }); +} + +beforeEach(async () => { + h = await useStateHarness(); + await publish(); + routeFetchToRegistry(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("skillx visibility against the registry", () => { + it("narrows public -> unlisted -> private with the owner's account key, audited, and reads it back", async () => { + vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null)); + expect(await runVisibilityGet("@alpha/demo", REG)).toEqual({ identity: "@alpha/demo", visibility: "public" }); + expect(await runVisibilitySet("@alpha/demo", "unlisted", REG)).toMatchObject({ from: "public", to: "unlisted", visibility: "unlisted", changed: true }); + expect(await runVisibilitySet("@alpha/demo", "private", REG)).toMatchObject({ from: "unlisted", to: "private", visibility: "private", changed: true }); + // The owner still reads the now-private package. + expect(await runVisibilityGet("@alpha/demo", REG)).toEqual({ identity: "@alpha/demo", visibility: "private" }); + expect(await runVisibilitySet("@alpha/demo", "private", REG)).toMatchObject({ changed: false }); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + expect(packageAudit(h).map((row) => [row.from_value, row.to_value])).toEqual([ + ["public", "unlisted"], + ["unlisted", "private"], + ]); + }); + + it("refuses widening with a key and names the dashboard; nothing is written", async () => { + vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null)); + await runVisibilitySet("@alpha/demo", "private", REG); + await expect(runVisibilitySet("@alpha/demo", "public", REG)).rejects.toMatchObject({ + code: "auth_error", + extra: { details: { status: 403, code: "visibility.approval_required" }, hint: expect.stringContaining(`${ORIGIN}/dashboard`) }, + }); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + expect(packageAudit(h)).toHaveLength(1); + }); + + it("refuses a stale --from, a publish key, and a key of another account", async () => { + vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", null)); + await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "unlisted" })).rejects.toMatchObject({ + code: "api_error", + extra: { details: { code: "visibility_conflict" }, hint: expect.stringContaining("skillx visibility get @alpha/demo") }, + }); + vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-a", [releasePublishScope("alpha")])); + await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "public" })).rejects.toMatchObject({ + code: "auth_error", + extra: { hint: expect.stringContaining("unscoped account API key") }, + }); + vi.stubEnv("SKILLX_API_KEY", await h.createKey("user-b", null)); + await expect(runVisibilitySet("@alpha/demo", "private", { ...REG, from: "public" })).rejects.toMatchObject({ code: "not_found" }); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("public"); + expect(packageAudit(h)).toEqual([]); + }); +}); diff --git a/apps/web/tests/releases/release-owner-access.test.ts b/apps/web/tests/releases/release-owner-access.test.ts index c0825de..ef654be 100644 --- a/apps/web/tests/releases/release-owner-access.test.ts +++ b/apps/web/tests/releases/release-owner-access.test.ts @@ -203,7 +203,7 @@ describe("private package reads: the owner", () => { expect(res.headers.get("Vary")).toBe("Authorization, Cookie"); } const list = await (await api.list(PRIVATE, { key: ownerKey })).json(); - expect(list).toMatchObject({ identity: PRIVATE, releases: [expect.objectContaining({ version: "1.0.0", releaseDigest: digest })] }); + expect(list).toMatchObject({ identity: PRIVATE, visibility: "private", releases: [expect.objectContaining({ version: "1.0.0", releaseDigest: digest })] }); session.user = { id: "user-a" }; for (const res of await everyRead(PRIVATE, digest)) expect(res.status).toBe(200); }); diff --git a/apps/web/tests/releases/release-private-publish.test.ts b/apps/web/tests/releases/release-private-publish.test.ts index 562c27f..d88c6ff 100644 --- a/apps/web/tests/releases/release-private-publish.test.ts +++ b/apps/web/tests/releases/release-private-publish.test.ts @@ -118,6 +118,11 @@ describe("publish visibility: never changed by a publish", () => { const writes = h.r2.putLog.length; const res = await publish("@alpha/notes", { visibility: requested, version: "1.1.0" }, " v2"); expect(res).toMatchObject({ status: 409, body: { error: { code: "release.visibility_mismatch" } } }); + // The refusal names the next step: change the visibility first. + // Only a narrowing can be done with the CLI's key; a widening is approved in the dashboard. + expect(res.body.error.message).toContain("/dashboard"); + if (requested === "private") expect(res.body.error.message).toContain("skillx visibility set"); + else expect(res.body.error.message).not.toContain("skillx visibility set"); expect(h.r2.putLog.length).toBe(writes); expect(h.releaseRows()).toHaveLength(1); expect(storedVisibility("@alpha/notes")).toBe(stored); diff --git a/apps/web/tests/releases/release-read.test.ts b/apps/web/tests/releases/release-read.test.ts index 42d497c..89511a6 100644 --- a/apps/web/tests/releases/release-read.test.ts +++ b/apps/web/tests/releases/release-read.test.ts @@ -112,6 +112,7 @@ describe("release read: happy path over real HTTP bodies", () => { expect(res.headers.get("Cache-Control")).toBe("public, max-age=60, no-transform"); expect(await res.json()).toEqual({ identity: "@alpha/demo", + visibility: "public", releases: [ expect.objectContaining({ version: "1.0.0", channel: "stable", state: "published", releaseDigest: digest, createdAt: "2026-10-13T12:00:00.000Z" }), ], diff --git a/docs/api-reference.md b/docs/api-reference.md index 40e47e3..6e37e6e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1302,7 +1302,7 @@ Read-only, behind the same flag: unless `SKILLX_RELEASES_API` is `"1"` or | Endpoint | Returns | |----------|---------| -| `GET /api/v1/packages/@{publisher}/{slug}/releases` | JSON list, newest first (at most 500): `version`, `channel`, `state`, `releaseDigest`, `artifactDigest`, `artifactSize`, `createdAt`. Published and yanked releases; a release is hidden when its tree is quarantined anywhere (it would answer `403`), so the list never offers one | +| `GET /api/v1/packages/@{publisher}/{slug}/releases` | `{identity, visibility, releases}`: the package visibility (`public`, `unlisted` or `private`, as stored) and the list, newest first (at most 500): `version`, `channel`, `state`, `releaseDigest`, `artifactDigest`, `artifactSize`, `createdAt`. Published and yanked releases; a release is hidden when its tree is quarantined anywhere (it would answer `403`), so the list never offers one | | `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}` | The signed `skillx.release/v1` or `v2` manifest bytes (new resolution: only `published`) | | `GET /api/v1/packages/@{publisher}/{slug}/releases/{version}/status` | The signed `skillx.release-status/v1` document of that release, in every state (see [Signed release status](#signed-release-status)) | | `GET /api/v1/releases/{releaseDigest}/manifest[?version=&package=]` | The same manifest, by tree digest (locked clients) | @@ -1751,7 +1751,8 @@ The publisher route answers `{"handle", "from", "to", "state", "changedAt"}`. ### Managing packages and kits (owner) The owner manages a package or personal kit from the web dashboard -(`/dashboard`) or with these endpoints. All are `POST` with an +(`/dashboard`), with these endpoints, or (package visibility) with +`skillx visibility set` (see [cli-reference.md](./cli-reference.md)). All are `POST` with an `application/json` body, behind `SKILLX_RELEASES_API` (`404 not_found` while it is off; kits also need `SKILLX_PERSONAL_KITS_API`, else `404 kit.api_disabled`), other methods `405`. Every answer is `Cache-Control: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index bea3b44..098840a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -387,7 +387,7 @@ skillx uninstall add:pdf --- -### Release commands: `validate`, `pack`, `publish`, `promote` +### Release commands: `validate`, `pack`, `publish`, `promote`, `visibility` These commands work on immutable, digest-pinned releases. Like every other command they take `--json` and follow @@ -458,7 +458,7 @@ not a local path (or no source at all) runs the deprecated import (see | `--kind ` | `skill`, `hook-pack` or `bundle` | `skill` | | `--repo ` | Provenance repository URL | `origin` remote (https form) | | `--script ` | Declare a packaged script (repeatable) | — | -| `--visibility ` | `public`, `unlisted` (readable by anyone who knows the name, never listed) or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`); change it in the web dashboard (`/dashboard`) instead. Publishing to a deleted package is refused with `package.deleted` | — | +| `--visibility ` | `public`, `unlisted` (readable by anyone who knows the name, never listed) or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`); the error's `hint` names the next step: change it first with `skillx visibility set` (a key may only make a package less visible) or in the web dashboard (`/dashboard`), or publish without `--visibility`. Publishing to a deleted package is refused with `package.deleted` | — | | `--kit-origin ` | Publish an `@agentkit/*` kit release (`remote-signed` or `local-dev`), signed as `skillx.release/v2`; needs a `releases:publish:@agentkit` key; the slug omits `ak-` (`@agentkit/plan`). Installing v2 releases needs skillx-sh 0.7.0 or later | — | | `--registry ` | Registry base URL; `https` only (`http` only for localhost) | configured base URL | | `--idempotency-key ` | Sends `Idempotency-Key`: a retry with the same key and bytes replays the first answer; other bytes are refused | — | @@ -507,6 +507,46 @@ demote. Every attempt is audited. ```bash skillx promote @acme/my-skill@1.2.0 --from beta --to stable --reason "soak passed" ``` +### `skillx visibility get|set <@publisher/slug>` + +Shows or changes who can read a published package: `public`, `unlisted` +(readable by anyone who knows the name, never listed) or `private`. + +- `skillx visibility get <@publisher/slug>` reads it from + `GET .../releases` (`visibility` member), with the API key when one is set, + so the owner can read a private package. +- `skillx visibility set <@publisher/slug> ` posts the + compare-and-set `POST /api/v1/packages/@publisher/slug/visibility` + (`{from, to}`, audited; see the + [API reference](./api-reference.md#managing-packages-and-kits-owner)). + `from` is `--from` when given, else read first with `get`. When the target equals + `from`, the CLI confirms the stored visibility (also when `--from` was + given) and sends no write (`data.changed: false`); a stored value that + differs is `api_error` with `error.details.code` `visibility_conflict`. + +It needs the owner's **unscoped** account API key (`SKILLX_API_KEY` or +`skillx config set-key`); a publish key (`releases:publish:@handle`) or any +other scoped key is refused (`auth_error`, exit `5`). A key may only make a +package less visible (`public` to `unlisted`, anything to `private`). Making it +more visible (out of `private`, or to `public`) needs a signed-in approval in +the web dashboard: the registry answers `403 visibility.approval_required` +(`auth_error`, exit `5`) and the `hint` names `/dashboard`. A +concurrent change is `409 visibility_conflict` (`api_error`, exit `1`; the hint +says to re-read with `get`), and an unknown package, or one you do not own, is +`not_found` (exit `3`). + +| Flag | Description | Default | +|------|-------------|---------| +| `--from ` | `set` only: expected current visibility; the change fails with `visibility_conflict` if it differs | read with `get` | +| `--registry ` | Registry base URL | configured base URL | +| `--json` | Print the result envelope: `get` `{identity, visibility}`, `set` `{identity, from, to, visibility, changed, changedAt?}` | — | + +```bash +skillx visibility get @acme/my-skill +skillx visibility set @acme/my-skill private # narrowing works with an account key +skillx visibility set @acme/my-skill public --json # widening: auth_error, hint names /dashboard +``` + --- ### `skillx library list` diff --git a/packages/cli/README.md b/packages/cli/README.md index f5ac9d3..ec709df 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -81,12 +81,17 @@ skillx import owner/repo --scan # scan all SKILL.md files skillx import --dry-run # preview without importing ``` -### Release commands: `validate`, `pack`, `publish `, `promote` +### Release commands: `validate`, `pack`, `publish `, `promote`, `visibility` Build and upload immutable, digest-pinned releases. With `--json` each prints one envelope, `{"ok": true, "data": {...}}` or `{"ok": false, "error": {...}}`, and the exit code follows it. See the [CLI reference](https://github.com/nextlevelbuilder/skillx/blob/main/docs/cli-reference.md). +Change a package's visibility with `skillx visibility set @acme/my-skill private` +(`skillx visibility get` shows it). It needs your unscoped account API key, which may +only make a package less visible; making it more visible is approved in the web +dashboard (`/dashboard`). + > **Breaking:** `skillx release publish --json` (now an alias of `skillx publish `) > used to print the bare release object, and failures only as text on stderr with exit > code `1`. It now prints the envelope: read the result from `.data` and a failure from diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts index e21444e..24476a1 100644 --- a/packages/cli/src/commands/release-publish.ts +++ b/packages/cli/src/commands/release-publish.ts @@ -5,6 +5,7 @@ import { KIT_ORIGINS } from '@skillx/skill-tree'; import { PACKAGE_VISIBILITIES } from '@skillx/contracts'; import type { PackageVisibility } from '@skillx/contracts'; import { getBaseUrl } from '../utils/config-store.js'; +import { withVisibilityMismatchHint } from '../lib/visibility-hints.js'; import { CliError, parsePackageName, publishCredential, registryFetch, runCommand, usageError } from '../lib/release-cli.js'; import { createPackCommand, packOrFail } from './pack.js'; import { packSummary } from './release-pack.js'; @@ -163,7 +164,9 @@ export async function runReleasePublish(input: string, options: ReleasePublishOp }), }; if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey; - const body = await registryFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { method: 'POST', headers, body: packed.tar }); + const body = await registryFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { method: 'POST', headers, body: packed.tar }).catch((error: unknown) => { + throw withVisibilityMismatchHint(error, base, name.name, visibility); + }); try { assertRegistryDigests(packed, body); } catch (error) { diff --git a/packages/cli/src/commands/visibility.test.ts b/packages/cli/src/commands/visibility.test.ts new file mode 100644 index 0000000..4813137 --- /dev/null +++ b/packages/cli/src/commands/visibility.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runVisibilityGet, runVisibilitySet } from './visibility.js'; +import { withVisibilityMismatchHint } from '../lib/visibility-hints.js'; +import { CliError } from '../lib/release-cli.js'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +const REG = { registry: 'https://reg.test' }; +const LIST = 'https://reg.test/api/v1/packages/@alpha/demo/releases'; +const SET = 'https://reg.test/api/v1/packages/@alpha/demo/visibility'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('skillx visibility get', () => { + it('reads the visibility from the release list, with the API key when one is set', async () => { + vi.stubEnv('SKILLX_API_KEY', 'sx_owner'); + const fetchMock = vi.fn(async () => json({ identity: '@alpha/demo', visibility: 'private', releases: [] })); + vi.stubGlobal('fetch', fetchMock); + expect(await runVisibilityGet('@alpha/demo', REG)).toEqual({ identity: '@alpha/demo', visibility: 'private' }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(LIST); + expect((init.headers as Record).Authorization).toBe('Bearer sx_owner'); + }); + + it('fails clearly against a registry that does not report visibility', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ identity: '@alpha/demo', releases: [] }))); + await expect(runVisibilityGet('@alpha/demo', REG)).rejects.toMatchObject({ code: 'api_error', extra: { hint: expect.stringContaining('--from') } }); + }); + + it('refuses a malformed package name as a usage error', async () => { + await expect(runVisibilityGet('alpha/demo', REG)).rejects.toMatchObject({ code: 'usage_error' }); + }); +}); + +describe('skillx visibility set', () => { + it('reads the current visibility, then posts the compare-and-set with the API key', async () => { + vi.stubEnv('SKILLX_API_KEY', 'sx_owner'); + const fetchMock = vi.fn(async (url: string) => + url === LIST ? json({ identity: '@alpha/demo', visibility: 'public', releases: [] }) : json({ identity: '@alpha/demo', from: 'public', to: 'private', visibility: 'private', changedAt: 't' }), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await runVisibilitySet('@alpha/demo', 'private', REG)).toMatchObject({ from: 'public', to: 'private', visibility: 'private', changed: true }); + const [url, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(url).toBe(SET); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ from: 'public', to: 'private' }); + expect((init.headers as Record).Authorization).toBe('Bearer sx_owner'); + }); + + it('uses --from without reading, and writes nothing when the stored visibility already matches', async () => { + vi.stubEnv('SKILLX_API_KEY', 'sx_owner'); + const fetchMock = vi.fn(async () => json({ visibility: 'unlisted' })); + vi.stubGlobal('fetch', fetchMock); + await runVisibilitySet('@alpha/demo', 'unlisted', { ...REG, from: 'public' }); + expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([SET]); + // --from equal to the target is confirmed against the registry, never trusted. + fetchMock.mockClear(); + fetchMock.mockResolvedValueOnce(json({ visibility: 'private' })); + expect(await runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'private' })).toMatchObject({ changed: false, visibility: 'private' }); + expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([LIST]); + fetchMock.mockResolvedValueOnce(json({ visibility: 'public' })); + await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'private' })).rejects.toMatchObject({ + code: 'api_error', + extra: { details: { code: 'visibility_conflict', stored: 'public' } }, + }); + expect(fetchMock.mock.calls.map((call) => (call as unknown as [string])[0])).toEqual([LIST, LIST]); + }); + + it('validates the visibility and --from before any request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('SKILLX_API_KEY', 'sx_owner'); + await expect(runVisibilitySet('@alpha/demo', 'internal', REG)).rejects.toMatchObject({ code: 'usage_error' }); + await expect(runVisibilitySet('@alpha/demo', 'public', { ...REG, from: 'Public' })).rejects.toMatchObject({ code: 'usage_error' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('names the dashboard when the registry refuses a widening, and the next step for other refusals', async () => { + vi.stubEnv('SKILLX_API_KEY', 'sx_owner'); + const fetchMock = vi.fn(async () => json({ error: { code: 'visibility.approval_required', message: 'needs approval' } }, 403)); + vi.stubGlobal('fetch', fetchMock); + await expect(runVisibilitySet('@alpha/demo', 'public', { ...REG, from: 'private' })).rejects.toMatchObject({ + code: 'auth_error', + extra: { hint: expect.stringContaining('https://reg.test/dashboard'), details: { status: 403, code: 'visibility.approval_required' } }, + }); + fetchMock.mockResolvedValueOnce(json({ error: { code: 'visibility_conflict', message: 'changed' } }, 409)); + await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'public' })).rejects.toMatchObject({ + code: 'api_error', + extra: { hint: expect.stringContaining('skillx visibility get @alpha/demo') }, + }); + fetchMock.mockResolvedValueOnce(json({ error: { code: 'invalid_api_key', message: 'scoped' } }, 401)); + await expect(runVisibilitySet('@alpha/demo', 'private', { ...REG, from: 'public' })).rejects.toMatchObject({ + code: 'auth_error', + extra: { hint: expect.stringContaining('unscoped account API key') }, + }); + }); +}); + +describe('publish visibility mismatch hint', () => { + it('adds the next step to release.visibility_mismatch and leaves other errors alone', () => { + const mismatch = new CliError('api_error', 'registry answered 409', { details: { status: 409, code: 'release.visibility_mismatch' } }); + const hinted = withVisibilityMismatchHint(mismatch, 'https://reg.test', '@alpha/demo', 'public') as CliError; + expect(hinted).toMatchObject({ code: 'api_error', message: 'registry answered 409', extra: { details: { code: 'release.visibility_mismatch' } } }); + expect(hinted.extra.hint).toContain('skillx visibility set @alpha/demo public'); + expect(hinted.extra.hint).toContain('https://reg.test/dashboard'); + const other = new CliError('api_error', 'x', { details: { status: 409, code: 'release.version_exists' } }); + expect(withVisibilityMismatchHint(other, 'https://reg.test', '@alpha/demo', 'public')).toBe(other); + }); +}); diff --git a/packages/cli/src/commands/visibility.ts b/packages/cli/src/commands/visibility.ts new file mode 100644 index 0000000..947f722 --- /dev/null +++ b/packages/cli/src/commands/visibility.ts @@ -0,0 +1,131 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { PACKAGE_VISIBILITIES, type PackageVisibility } from '@skillx/contracts'; +import { CliError, parsePackageName, registryFetch, runCommand, usageError } from '../lib/release-cli.js'; +import { getApiKey } from '../utils/config-store.js'; +import { dashboardUrl, visibilityHint } from '../lib/visibility-hints.js'; +import { resolveRegistry } from './release-publish.js'; + +interface VisibilityOptions { + registry?: string; + json?: boolean; +} + +interface SetOptions extends VisibilityOptions { + from?: string; +} + +function parseVisibility(raw: string | undefined, label: string): PackageVisibility { + if (!(PACKAGE_VISIBILITIES as readonly string[]).includes(raw ?? '')) { + throw usageError(`${label} must be one of: ${PACKAGE_VISIBILITIES.join(', ')}, got ${JSON.stringify(raw ?? '')}`); + } + return raw as PackageVisibility; +} + +function packageName(raw: string) { + try { + return parsePackageName(raw); + } catch { + throw usageError(`expected @publisher/slug, got ${JSON.stringify(raw)}`); + } +} + +async function ownerFetch(url: string, init: RequestInit, base: string, name: string): Promise> { + try { + return await registryFetch(url, init); + } catch (error) { + if (!(error instanceof CliError)) throw error; + const hint = visibilityHint((error.extra.details ?? {}) as { status?: number; code?: string | null }, base, name) ?? error.extra.hint; + throw new CliError(error.code, error.message, { ...error.extra, ...(hint ? { hint } : {}) }); + } +} + +function authHeaders(): Record { + const apiKey = getApiKey(); + return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; +} + +/** Reads the stored visibility from the release list (`GET .../releases`), as the caller may see it. */ +export async function runVisibilityGet(target: string, options: VisibilityOptions) { + const name = packageName(target); + const base = resolveRegistry(options.registry); + const body = await ownerFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { headers: authHeaders() }, base, name.name); + if (typeof body.visibility !== 'string') { + throw new CliError('api_error', 'the registry did not report the package visibility', { + hint: `Upgrade the registry, or pass --from to \`skillx visibility set\`; the dashboard shows it: ${dashboardUrl(base)}.`, + }); + } + return { identity: name.name, visibility: parseVisibility(body.visibility, 'the registry visibility') }; +} + +/** + * Changes a package's visibility (`POST .../visibility`, a compare-and-set + * audited by the registry). Needs the owner's unscoped API key. A key may + * narrow (to `unlisted` from `public`, anything to `private`); widening needs + * a signed-in approval in the web dashboard, so the registry refuses it here + * and the error names the dashboard. + */ +export async function runVisibilitySet(target: string, to: string, options: SetOptions) { + const name = packageName(target); + const next = parseVisibility(to, 'visibility'); + const expected = options.from === undefined ? undefined : parseVisibility(options.from, '--from'); + const base = resolveRegistry(options.registry); + const apiKey = getApiKey(); + if (!apiKey) { + throw new CliError('auth_error', 'an API key is required', { + hint: `Set SKILLX_API_KEY or run \`skillx config set-key\` with an unscoped account key; or use ${dashboardUrl(base)}.`, + }); + } + const from = expected ?? (await runVisibilityGet(target, options)).visibility; + if (from === next) { + // `--from` is only a claim: confirm the stored value before reporting that nothing needs to change. + const stored = expected === undefined ? from : (await runVisibilityGet(target, options)).visibility; + if (stored !== next) { + throw new CliError('api_error', `${name.name} is ${stored}, not ${next} as --from expected`, { + details: { code: 'visibility_conflict', stored }, + hint: visibilityHint({ code: 'visibility_conflict' }, base, name.name), + }); + } + return { identity: name.name, from, to: next, visibility: next, changed: false }; + } + const body = await ownerFetch( + `${base}/api/v1/packages/@${name.handle}/${name.slug}/visibility`, + { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: next }) }, + base, + name.name, + ); + return { ...body, identity: name.name, from, to: next, changed: true }; +} + +const registryOption = ['--registry ', 'registry base URL (default: configured base URL)'] as const; +const jsonOption = ['--json', 'print the result as a JSON envelope'] as const; + +const getCommand = new Command('get') + .description('Show the visibility of a package you can read') + .argument('', '@publisher/slug') + .option(...registryOption) + .option(...jsonOption) + .action(async (target: string, options: VisibilityOptions) => { + await runCommand('visibility get', options.json, () => runVisibilityGet(target, options), (result) => { + console.log(`${result.identity}: ${chalk.cyan(result.visibility)}`); + }); + }); + +const setCommand = new Command('set') + .description('Change the visibility of your package (audited); making it more visible needs the web dashboard') + .argument('', '@publisher/slug') + .argument('', PACKAGE_VISIBILITIES.join(' | ')) + .option('--from ', 'expected current visibility (default: read from the registry); the change fails if it differs') + .option(...registryOption) + .option(...jsonOption) + .action(async (target: string, to: string, options: SetOptions) => { + await runCommand('visibility set', options.json, () => runVisibilitySet(target, to, options), (result) => { + if (!result.changed) console.log(`${result.identity} is already ${chalk.cyan(result.visibility)}`); + else console.log(chalk.bold.green(`${result.identity}: ${result.from} -> ${result.to}`)); + }); + }); + +export const visibilityCommand = new Command('visibility') + .description('Show or change the visibility (public, unlisted, private) of a published package') + .addCommand(getCommand) + .addCommand(setCommand); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9ac0e24..78112cf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,7 @@ import { importCommand } from './commands/import.js'; import { validateCommand } from './commands/validate.js'; import { createPackCommand } from './commands/pack.js'; import { promoteCommand } from './commands/promote.js'; +import { visibilityCommand } from './commands/visibility.js'; import { releaseCommand } from './commands/release-publish.js'; import { installCommand, listCommand, syncCommand, uninstallCommand, updateCommand } from './commands/install.js'; import { mcpCommand } from './commands/mcp.js'; @@ -42,6 +43,7 @@ program.addCommand(validateCommand); program.addCommand(createPackCommand()); program.addCommand(publishCommand); program.addCommand(promoteCommand); +program.addCommand(visibilityCommand); program.addCommand(releaseCommand); for (const command of [installCommand, updateCommand, uninstallCommand, listCommand, syncCommand, mcpCommand]) program.addCommand(command); program.addCommand(addCommand); diff --git a/packages/cli/src/lib/visibility-hints.ts b/packages/cli/src/lib/visibility-hints.ts new file mode 100644 index 0000000..005b258 --- /dev/null +++ b/packages/cli/src/lib/visibility-hints.ts @@ -0,0 +1,34 @@ +/** + * Next-step hints for package visibility failures, shared by `skillx + * visibility` and `skillx publish`: the CLI holds an API key, which may narrow + * a package's visibility; widening needs a signed-in approval in the web + * dashboard of the same registry. + */ +import type { PackageVisibility } from '@skillx/contracts'; +import { CliError } from './output.js'; + +/** Where the owner changes what the CLI may not: the web dashboard of the registry. */ +export const dashboardUrl = (base: string) => `${base}/dashboard`; + +/** The next step for a registry refusal of a visibility change, by its error code. */ +export function visibilityHint(failure: { status?: number; code?: string | null }, base: string, name: string): string | undefined { + if (failure.status === 401) return `Use the owner's unscoped account API key (publish and other scoped keys are refused): \`skillx config set-key\`, or ${dashboardUrl(base)}.`; + switch (failure.code) { + case 'visibility.approval_required': + return `Making a package more visible needs a signed-in approval: open ${dashboardUrl(base)}.`; + case 'visibility_conflict': + return `The visibility changed meanwhile; check it with \`skillx visibility get ${name}\` and retry.`; + default: + return undefined; + } +} + +/** Names the next step of a `release.visibility_mismatch` refusal: change the visibility first, then publish again. */ +export function withVisibilityMismatchHint(error: unknown, base: string, name: string, requested: PackageVisibility | undefined): unknown { + if (!(error instanceof CliError) || (error.extra.details as { code?: unknown } | undefined)?.code !== 'release.visibility_mismatch') return error; + const hint = + `Change the package visibility first, then publish again: \`skillx visibility set ${name} ${requested ?? ''}\` ` + + `(an account API key may make it less visible; making it more visible needs ${dashboardUrl(base)}), or publish without --visibility to keep it.`; + return new CliError(error.code, error.message, { ...error.extra, hint }); +} +