Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.1.4 — 2026-03-29

- **Breaking (контракт JSON):** `SkillDetail` приведён к канону `GET /skills/{name}` из [registry-api.md](https://github.com/getskillpack/registry/blob/main/docs/registry-api.md): поле `versions` — объект `map[version]`, флаг **`yanked`**, без вымышленного `latest_version` на детали скилла.
- `ResolveInstallTarget` без pin: выбор **максимальной semver** среди неснятых версий (как `latestNonYanked` в reference store).
- `FetchJSON` и загрузка архива: опциональный `Authorization: Bearer` из `SKILLGET_REGISTRY_READ_TOKEN` или fallback на токен записи (`RegistryReadBearer`).
- Документ для board/интеграций: [docs/REGISTRY_CLIENT_CONTRACT.md](docs/REGISTRY_CLIENT_CONTRACT.md).
- Зависимость: `golang.org/x/mod/semver` для сортировки версий.

## 0.1.3 — 2026-03-28

- `DownloadSkillArchive`: подсказки при сетевых сбоях и при ответах **401 / 403 / 404 / 429 / 503** от хоста архива (URL может отличаться от базы реестра).
Expand Down Expand Up @@ -27,7 +35,3 @@
- Сообщения об ошибках HTTP к реестру с краткими подсказками (как в TS `registry.ts`).
- Документация: `SECURITY.md`, ссылки на лицензию и процесс безопасности в README.

## Unreleased

- README: явно описаны подсказки в ошибках для запросов к реестру и для скачивания архива.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ CLI ([cli](https://github.com/getskillpack/cli)) остаётся тонкой

- `RegistryBaseURL` / `RegistryConfigSource` — выбор базы API из env (`SKILLGET_REGISTRY_URL`, legacy `SKPKG_REGISTRY_URL`).
- `RegistryToken` — bearer для записи в реестр из `SKILLGET_REGISTRY_TOKEN` или `SKILLGET_TOKEN`.
- `RegistryReadBearer` — bearer для чтения (`SKILLGET_REGISTRY_READ_TOKEN` или, если пусто, тот же токен, что и для записи): добавляется к `GET` API и к загрузке `archive_url`, когда токен задан.
- `FetchJSON` — JSON GET к реестру (через `context.Context`); типичные ответы реестра сопровождаются короткими подсказками (сеть, токен, 404, 409, 429 и др.).
- `ReadSkillsLock` / `WriteSkillsLock` — файл `skills.lock`.
- `SearchSkills` — список/поиск скиллов (опции `Query`, `Author`, `Limit`, `Offset`).
- `ParseNameVersion` / `ResolveInstallTarget` — разрешение версии и метаданные архива (для «latest» поле `latest_version`, если оно не помечено как yanked в списке версий; иначе первая неснятая версия).
- `ParseNameVersion` / `ResolveInstallTarget` — разрешение версии и метаданные архива; для «latest» без pin — **наибольшая semver** среди записей `versions` в ответе `GET /skills/{name}`, исключая `yanked: true` (как в reference registry).
- `DownloadSkillArchive` — скачивание tarball, проверка `checksum` вида `sha256:<hex>` при наличии, обновление lockfile; при сбоях HTTP к **archive URL** (часто отдельно от базы реестра) — свои подсказки (401–429, 503, сеть).
- `PublishSkill` — multipart POST `/skills` (manifest + archive), как у TS-клиента в `skpkg-cli`.

Контракт HTTP API реестра: [registry/API.md](https://github.com/getskillpack/registry/blob/main/API.md).
Контракт HTTP API реестра: [registry/API.md](https://github.com/getskillpack/registry/blob/main/API.md). Поведение этой библиотеки как клиента: [docs/REGISTRY_CLIENT_CONTRACT.md](docs/REGISTRY_CLIENT_CONTRACT.md).

## Разработка

Expand Down
3 changes: 3 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func FetchJSON(ctx context.Context, path string, out any) error {
return err
}
req.Header.Set("Accept", "application/json")
if t := RegistryReadBearer(); t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}

res, err := HTTPClient.Do(req)
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ func RegistryToken() string {
}
return trimToken(os.Getenv("SKILLGET_TOKEN"))
}

// RegistryReadBearer returns the Bearer token for GET /api/v1/* and archive downloads
// when the registry operator enabled REGISTRY_READ_TOKEN. SKILLGET_REGISTRY_READ_TOKEN
// wins when set; otherwise falls back to RegistryToken() so one secret can cover read+write.
func RegistryReadBearer() string {
if t := trimToken(os.Getenv("SKILLGET_REGISTRY_READ_TOKEN")); t != "" {
return t
}
return RegistryToken()
}
52 changes: 52 additions & 0 deletions docs/REGISTRY_CLIENT_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Контракт клиента skillget-manager ↔ registry HTTP API

Канон спецификации сервера: [`docs/registry-api.md`](https://github.com/getskillpack/registry/blob/main/docs/registry-api.md) (раздел **Compiled core**). Этот документ фиксирует поведение библиотеки `github.com/getskillpack/skillget-manager` как **скомпилированного клиента**.

## Переменные окружения

| Переменная | Назначение |
|------------|------------|
| `SKILLGET_REGISTRY_URL` | Базовый URL API **без** завершающего `/`: `{origin}/api/v1`. Имеет приоритет. |
| `SKPKG_REGISTRY_URL` | Устаревший алиас для базы API. |
| `SKILLGET_REGISTRY_READ_TOKEN` | Bearer для **GET** к `/api/v1/*` и для **GET** по `archive_url`, если оператор реестра включил `REGISTRY_READ_TOKEN`. |
| `SKILLGET_REGISTRY_TOKEN` | Bearer для **записи** (`POST /skills`, `DELETE …/versions/…`). |
| `SKILLGET_TOKEN` | Устаревший алиас для токена записи. |

Если задан только `SKILLGET_REGISTRY_READ_TOKEN`, анонимные запросы к API не выполняются: чтение идёт с этим Bearer. Если `SKILLGET_REGISTRY_READ_TOKEN` пуст, для чтения используется тот же токен, что и для записи (`SKILLGET_REGISTRY_TOKEN` / `SKILLGET_TOKEN`), если он задан — это упрощает сценарий «один секрет на приватный реестр».

Публичный реестр по умолчанию: `https://registry.skpkg.org/api/v1` (без обязательных токенов для чтения).

## HTTP: маршруты и коды

Пути ниже относительны к базе из `SKILLGET_REGISTRY_URL`.

| Действие в библиотеке | Метод и путь | Успех | Ошибки (ожидания клиента) |
|------------------------|--------------|-------|---------------------------|
| `SearchSkills` | `GET /skills?…` | `200` + JSON | `401` при read-token на сервере |
| `ResolveInstallTarget` (без pin) | `GET /skills/{name}` | `200` + JSON detail | `404`, `401` |
| `ResolveInstallTarget` (все случаи) | `GET /skills/{name}/versions/{version}` | `200` + JSON version | `404`, **`410`** после yank, `401` |
| `PublishSkill` | `POST /skills` (multipart `manifest` + `archive`) | **`201`** пустое тело | `400`, `401`, `409`, `503` без write token |
| (сервер) yank | `DELETE /skills/{name}/versions/{version}` | **`204`** | `401`, `404` |

Тела ошибок reference-сервера — короткий `text/plain` (не JSON). Сообщения об ошибках в Go дополняются подсказками (`registry_errors.go`).

## JSON: формы ответов

- **Список** `GET /skills` — объект с полями `data[]`, `meta` (`total`, `limit`, `offset`). Элементы `data` содержат как минимум `name`, `latest_version`, `created_at`, `description`, `author` (как в спецификации).
- **Деталь скилла** `GET /skills/{name}` — поля `name`, `description`, `author`, `created_at`, **`versions`** — объект, ключи = semver-строки версий, значения: `manifest`, `checksum` (`sha256:` + 64 hex), `archive_url`, `published_at`, `yanked`.
- **Версия** `GET /skills/{name}/versions/{version}` — `name`, `version`, `manifest`, `archive_url`, `checksum`.

Библиотека **не** использует несуществующее в каноне поле `latest_version` на ответе `GET /skills/{name}`: для установки без pin выбирается **наибольшая по semver** неснятая (`yanked: false`) версия из `versions`, в духе логики `latestNonYanked` в reference store.

## Публикация и архив

- `PublishSkill`: `multipart/form-data`, поля `manifest` (JSON-строка) и `archive` (файл `.tar.gz`), заголовок `Authorization: Bearer <write token>`.
- `DownloadSkillArchive`: GET по абсолютному `archive_url` из ответа версии; при непустом `RegistryReadBearer()` добавляется тот же `Authorization`, что и к API (для приватных `/downloads/*`).

## Проверка целостности

Если в метаданных версии присутствует `checksum` в формате `sha256:<64 hex>`, после скачивания выполняется сверка SHA-256 с содержимым файла.

## Соответствие ENGINEERING_REQUIREMENTS_SKPKG.md

Файл `plans/ENGINEERING_REQUIREMENTS_SKPKG.md` в onboarding/CTO workspace не входит в этот репозиторий. Стек менеджера — **Go**, контракт с реестром — **HTTP + JSON** по канону выше; расхождения с инженерным чеклистом фиксируются в рабочих тикетах Paperclip (см. раздел в `registry-api.md`).
3 changes: 3 additions & 0 deletions download.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func DownloadSkillArchive(ctx context.Context, spec string, opts DownloadSkillOp
if err != nil {
return nil, err
}
if t := RegistryReadBearer(); t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
Comment on lines +84 to +86

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unconditionally adds the registry bearer to whatever absolute archive_url is returned. If archive_url can point to a different host (e.g., CDN/S3 presigned URLs), this can leak read/write tokens cross-origin. Consider only attaching Authorization when the archive URL host matches the registry host (or when an explicit allowlist/opt-in env var is set).

Copilot uses AI. Check for mistakes.
res, err := HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("archive request failed: %w%s", err, archiveRequestNetworkHint())
Expand Down
100 changes: 96 additions & 4 deletions download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestDownloadSkillArchive_checksumOK(t *testing.T) {
Expand All @@ -24,8 +26,12 @@ func TestDownloadSkillArchive_checksumOK(t *testing.T) {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/demo":
lv := "1.0.0"
_ = json.NewEncoder(w).Encode(SkillDetail{Name: "demo", LatestVersion: &lv})
_ = json.NewEncoder(w).Encode(SkillDetail{
Name: "demo",
Versions: map[string]VersionPublicInfo{
"1.0.0": {Yanked: false},
},
})
case "/api/v1/skills/demo/versions/1.0.0":
_ = json.NewEncoder(w).Encode(VersionDetail{
Name: "demo",
Expand Down Expand Up @@ -62,8 +68,12 @@ func TestDownloadSkillArchive_checksumMismatch(t *testing.T) {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/demo":
lv := "1.0.0"
_ = json.NewEncoder(w).Encode(SkillDetail{Name: "demo", LatestVersion: &lv})
_ = json.NewEncoder(w).Encode(SkillDetail{
Name: "demo",
Versions: map[string]VersionPublicInfo{
"1.0.0": {},
},
})
case "/api/v1/skills/demo/versions/1.0.0":
_ = json.NewEncoder(w).Encode(VersionDetail{
Name: "demo",
Expand Down Expand Up @@ -92,3 +102,85 @@ func TestDownloadSkillArchive_checksumMismatch(t *testing.T) {
t.Fatal("expected archive removed on checksum failure")
}
}

func TestDownloadSkillArchive_sendsReadBearerToRegistry(t *testing.T) {
prev := HTTPClient
t.Cleanup(func() { HTTPClient = prev })

var sawAuth string
var ts *httptest.Server
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/demo":
sawAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(SkillDetail{
Name: "demo",
Versions: map[string]VersionPublicInfo{"1.0.0": {}},
})
case "/api/v1/skills/demo/versions/1.0.0":
sawAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(VersionDetail{
Name: "demo",
Version: "1.0.0",
ArchiveURL: ts.URL + "/blob.tgz",
})
case "/blob.tgz":
if r.Header.Get("Authorization") != "Bearer readtok" {
http.Error(w, "no auth", http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("x"))
default:
http.NotFound(w, r)
}
}))
defer ts.Close()

t.Setenv("SKILLGET_REGISTRY_URL", ts.URL+"/api/v1")
t.Setenv("SKILLGET_REGISTRY_READ_TOKEN", "readtok")
HTTPClient = ts.Client()

dir := t.TempDir()
_, err := DownloadSkillArchive(context.Background(), "demo", DownloadSkillOptions{Cwd: dir})
if err != nil {
t.Fatal(err)
}
if sawAuth != "Bearer readtok" {
t.Fatalf("expected Bearer on registry requests, got %q", sawAuth)
}
}

func TestSkillDetail_decodesRegistryShape(t *testing.T) {
raw := `{
"name": "x",
"description": "d",
"author": "a",
"created_at": "2026-03-27T00:00:00Z",
"versions": {
"1.0.0": {
"manifest": {"name":"x","version":"1.0.0"},
"checksum": "sha256:abababababababababababababababababababababababababababababababab",
"archive_url": "https://registry.example/downloads/ab.tar.gz",
"published_at": "2026-03-27T00:00:00Z",
"yanked": false
}
}
}`
var d SkillDetail
if err := json.Unmarshal([]byte(raw), &d); err != nil {
t.Fatal(err)
}
if d.Name != "x" || d.Description != "d" || d.Author != "a" {
t.Fatalf("top-level: %+v", d)
}
if !d.CreatedAt.Equal(time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("created_at: %v", d.CreatedAt)
}
v, ok := d.Versions["1.0.0"]
if !ok {
t.Fatal("missing version")
}
if v.Yanked || !strings.HasPrefix(string(v.Manifest), "{") {
t.Fatalf("version info: %+v", v)
}
}
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
module github.com/getskillpack/skillget-manager

go 1.22
go 1.22.0

toolchain go1.22.12

require golang.org/x/mod v0.23.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
2 changes: 1 addition & 1 deletion registry_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ func registryHintForStatus(status int) string {
case 400:
return "\nHint: request may be invalid — check query params and paths against the registry API."
case 401:
return "\nHint: set SKILLGET_REGISTRY_TOKEN (or SKILLGET_TOKEN) for authenticated requests."
return "\nHint: set SKILLGET_REGISTRY_READ_TOKEN for GET /api/v1 (read token), or SKILLGET_REGISTRY_TOKEN / SKILLGET_TOKEN if the same bearer is used for reads."
case 403:
return "\nHint: token may lack permission, or the registry blocks this operation for anonymous clients."
case 404:
Expand Down
45 changes: 23 additions & 22 deletions resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"context"
"fmt"
"net/url"
"sort"
"strings"

"golang.org/x/mod/semver"
)

// NameVersion holds a parsed skill spec "name" or "name@version".
Expand Down Expand Up @@ -48,34 +51,32 @@ func ResolveInstallTarget(ctx context.Context, spec string) (*VersionDetail, err
return &vd, nil
}

// pickInstallableVersion chooses a version for an unpinned install.
// Prefers registry latest_version when present and not marked yanked in versions[];
// otherwise the first non-yanked entry in versions (registry order).
// pickInstallableVersion chooses the highest semver among non-yanked versions in the
// skill detail map, matching reference registry logic (see getskillpack/registry filestore).
func pickInstallableVersion(d SkillDetail) string {
if d.LatestVersion != nil {
candidate := strings.TrimSpace(*d.LatestVersion)
if candidate != "" && !versionMarkedYanked(d, candidate) {
return candidate
}
if len(d.Versions) == 0 {
return ""
}
for _, v := range d.Versions {
if v.IsYanked {
var keys []string
for v, info := range d.Versions {
v = strings.TrimSpace(v)
if v == "" || info.Yanked {
continue
}
if v.Version != "" {
return v.Version
}
keys = append(keys, v)
}
return ""
if len(keys) == 0 {
return ""
}
sort.Slice(keys, func(i, j int) bool {
return semver.Compare(canonicalSemver(keys[i]), canonicalSemver(keys[j])) < 0
})
return keys[len(keys)-1]
Comment on lines +60 to +74

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selecting the highest semver currently sorts all version keys (O(n log n) + alloc). Since you only need the max, this can be done in a single pass by tracking the best version with semver.Compare, which is simpler and more efficient for skills with many versions.

Copilot uses AI. Check for mistakes.
}

// versionMarkedYanked reports whether v appears in d.Versions with is_yanked true.
// If v is not listed, returns false (trust latest_version from the registry).
func versionMarkedYanked(d SkillDetail, v string) bool {
for _, row := range d.Versions {
if row.Version == v {
return row.IsYanked
}
func canonicalSemver(v string) string {
if !strings.HasPrefix(v, "v") {
return "v" + v
}
return false
return v
}
Loading
Loading