diff --git a/CLAUDE.md b/CLAUDE.md index 286f361..fdb1198 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1 | `/api/v1/oidc/github/token` | api.v1.oidc-github-token.ts (GitHub Actions JWT → 15 min `sxo_` token scoped `releases:publish:@handle`; jti replay-checked) | GitHub OIDC JWT (503 without `SKILLX_TOKEN_SECRET`; 404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/releases/:digest/artifact/url`, `/api/v1/downloads/:token` | api.v1.release-download-url.ts, api.v1.release-download.ts (5 min signed artifact URL for private/entitled releases) | URL: entitled Key/Session; download: the token (404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/kits/:scope/:name`, `/api/v1/kits/:scope/:name/*` | api.v1.kits.ts (personal kit revisions, rebase, conflicts, session-derived proposals and review, activate/rollback, lock; see docs/personal-kits-api.md) | Owner session or unscoped API key; scoped keys 401, non-owners 404; `revisions/:id/quarantine` admin secret only (404 unless `SKILLX_PERSONAL_KITS_API` on) | +| `/api/v1/collections`, `/api/v1/collections/:id`, `/api/v1/collections/:id/*` | api.v1.collections.ts (collections: create/update/delete, immutable revisions, members, clone; see docs/api-reference.md#collections) | Reads: owner, or anyone for unlisted/public; writes: owner session or unscoped key (404 unless `SKILLX_COLLECTIONS_API` on) | ## Database Tables (Drizzle schema) diff --git a/apps/web/app/lib/collections/collection-http.ts b/apps/web/app/lib/collections/collection-http.ts new file mode 100644 index 0000000..fff9d1f --- /dev/null +++ b/apps/web/app/lib/collections/collection-http.ts @@ -0,0 +1,91 @@ +/** + * HTTP boundary of the collections API (`/api/v1/collections[/...]`, see + * docs/api-reference.md#collections). 404 everywhere while + * `SKILLX_COLLECTIONS_API` is off. Reads accept an unscoped API key, the web + * session or no credentials (public and unlisted collections only) and count + * against `RELEASE_READ_LIMITER`; writes need an owner (session or unscoped + * key, never the admin secret or a scoped key) and count against + * `RELEASE_WRITE_LIMITER` (fail closed) before the body is read. A private + * collection of someone else answers the same 404 as an unknown one. Every + * answer is `no-store`; failures are `{error:{code,message}}`. + */ +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "~/lib/releases/release-http"; +import { checkReleaseRateLimit, rateLimitCaller } from "~/lib/releases/release-rate-limit"; +import { resolveViewer } from "~/lib/releases/release-read"; +import { NO_STORE, failureHeaders, readJsonObject, resolveLimitedActor } from "~/lib/releases/release-state-http"; + +export type CollectionEnv = Env & { SKILLX_COLLECTIONS_API?: string }; + +/** A full revision (256 members) fits easily; larger bodies are refused before parsing. */ +export const MAX_COLLECTION_BODY_BYTES = 256 * 1024; + +export function collectionsApiEnabled(env: { SKILLX_COLLECTIONS_API?: string }): boolean { + const value = (env.SKILLX_COLLECTIONS_API ?? "").trim().toLowerCase(); + return value === "1" || value === "true"; +} + +export const collectionNotFound = () => releaseError(404, "not_found", "Not found"); +export const badRequest = (message: string) => releaseError(400, "invalid_request", message); + +export const json = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE }); + +/** A refusal that carries validation issues next to `code` and `message`. */ +export class CollectionValidationError extends ReleaseHttpError { + readonly issues: Array<{ path: string; code: string }>; + + constructor(issues: Array<{ path: string; code: string }>) { + super(422, "collection.invalid", `invalid collection content: ${issues.map((i) => `${i.path} ${i.code}`).join(", ")}`); + this.issues = issues; + } +} + +export interface CollectionCaller { + /** The signed-in user, or null for an anonymous read. */ + userId: string | null; + env: CollectionEnv; + request: Request; + now: Date; +} + +export const readBody = (request: Request, fields: readonly string[]) => readJsonObject(request, new Set(fields), MAX_COLLECTION_BODY_BYTES); + +export function requireRevisionNumber(raw: unknown, field = "expectedRevision"): number { + if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) throw badRequest(`${field} must be a non-negative integer`); + return raw; +} + +/** Runs one endpoint: flag, caller and limit, then the handler; maps every failure to the error shape. */ +export async function serveCollections( + request: Request, + env: CollectionEnv, + access: "read" | "write", + handler: (caller: CollectionCaller) => Promise, + now: () => Date = () => new Date(), +): Promise { + if (!collectionsApiEnabled(env)) return notFoundResponse(); + try { + let userId: string | null; + if (access === "read") { + userId = await resolveViewer(request, env); + const decision = await checkReleaseRateLimit(env.RELEASE_READ_LIMITER, rateLimitCaller(request, userId)); + if (!decision.allowed) { + throw Object.assign(releaseError(429, "rate_limited", `too many requests; retry after ${decision.retryAfterSeconds} seconds`), { retryAfter: decision.retryAfterSeconds }); + } + } else { + const actor = await resolveLimitedActor(request, env); + if (actor.kind !== "user") throw releaseError(403, "collection.owner_only", "collections are written by their owner, not by operators"); + userId = actor.id; + } + return await handler({ userId, env, request, now: now() }); + } catch (error) { + if (error instanceof CollectionValidationError) { + return Response.json({ error: { code: error.code, message: error.message, issues: error.issues } }, { status: 422, headers: NO_STORE }); + } + if (error instanceof ReleaseHttpError) { + const retry = (error as { retryAfter?: number }).retryAfter; + return errorResponse(error, retry === undefined ? failureHeaders(error) : { "Retry-After": String(retry) }); + } + console.error("collection request failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "collection.internal_error", "collection request failed")); + } +} diff --git a/apps/web/app/lib/collections/collection-router.ts b/apps/web/app/lib/collections/collection-router.ts new file mode 100644 index 0000000..e8dae54 --- /dev/null +++ b/apps/web/app/lib/collections/collection-router.ts @@ -0,0 +1,181 @@ +/** + * Dispatch of `/api/v1/collections[/:id[/]]` by method and sub-path. + * GETs are reads (anonymous allowed for non-private collections); POST, + * PATCH and DELETE are owner writes. A collection is a recipe of package + * references; per-user overlays and activation belong to personal kits. + */ +import { COLLECTION_VISIBILITIES } from "@skillx/contracts"; +import type { CollectionMember } from "@skillx/contracts"; +import { notFoundResponse, releaseError } from "~/lib/releases/release-http"; +import { badRequest, json, readBody, requireRevisionNumber, serveCollections } from "./collection-http"; +import type { CollectionCaller, CollectionEnv } from "./collection-http"; +import { + appendRevision, + collectionView, + deleteCollection, + findRevision, + insertCollection, + listOwned, + listRevisions, + ownedCollection, + prepareContent, + readableCollection, + revisionView, + updateMetadata, +} from "./collection-store"; + +const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ID = /^col_[0-9a-f-]{36}$/; + +function requireSlug(raw: unknown): string { + if (typeof raw !== "string" || !SLUG.test(raw)) throw badRequest("slug must be 1 to 64 lowercase letters, digits or dashes"); + return raw; +} +function requireName(raw: unknown, fallback?: string): string { + if (raw === undefined && fallback !== undefined) return fallback; + if (typeof raw !== "string" || raw.trim() === "" || raw.length > 120) throw badRequest("name must be 1 to 120 characters"); + return raw.trim(); +} +function requireVisibility(raw: unknown, fallback = "private"): string { + if (raw === undefined) return fallback; + if (!(COLLECTION_VISIBILITIES as readonly unknown[]).includes(raw)) throw badRequest(`visibility must be one of ${COLLECTION_VISIBILITIES.join(", ")}`); + return raw as string; +} + +/** The member list of `base` with `add` upserted by package and `remove` dropped (each must exist). */ +export function editMembers(base: CollectionMember[], add: unknown, remove: unknown): unknown[] { + if (add !== undefined && !Array.isArray(add)) throw badRequest("add must be an array of members"); + if (remove !== undefined && (!Array.isArray(remove) || remove.some((r) => typeof r !== "string"))) throw badRequest("remove must be an array of package names"); + const byName = new Map(base.map((m) => [m.packageName, m])); + for (const name of (remove ?? []) as string[]) { + if (!byName.delete(name)) throw releaseError(422, "collection.member_missing", `${name} is not a member`); + } + for (const member of (add ?? []) as unknown[]) { + const name = typeof member === "object" && member !== null ? (member as { packageName?: unknown }).packageName : undefined; + if (typeof name !== "string") throw badRequest("every added member needs a packageName"); + byName.set(name, member); + } + return [...byName.values()]; +} + +type Handler = (c: CollectionCaller, id: string, args: string[]) => Promise; +interface RouteDef { + pattern: RegExp; + get?: Handler; + post?: Handler; + patch?: Handler; + delete?: Handler; +} + +const ROUTES: RouteDef[] = [ + { + pattern: /^$/, + get: async (c, id) => json({ collection: collectionView(await readableCollection(c, id), c.userId) }), + patch: async (c, id) => { + const row = await ownedCollection(c, id); + const b = await readBody(c.request, ["name", "visibility"]); + const updated = await updateMetadata(c, row, requireName(b.name, row.name), requireVisibility(b.visibility, row.visibility)); + return json({ collection: collectionView(updated, c.userId) }); + }, + delete: async (c, id) => { + await deleteCollection(c, await ownedCollection(c, id)); + return json({ deleted: true, id }); + }, + }, + { + pattern: /^revisions$/, + get: async (c, id) => { + await readableCollection(c, id); + return json({ revisions: (await listRevisions(c, id)).map(revisionView) }); + }, + post: async (c, id) => { + const row = await ownedCollection(c, id); + const b = await readBody(c.request, ["expectedRevision", "members", "config"]); + const revision = await appendRevision(c, row, requireRevisionNumber(b.expectedRevision), await prepareContent(b.members, b.config)); + return json({ revision: revisionView(revision) }, 201); + }, + }, + { + pattern: /^revisions\/(\d{1,9}|latest)$/, + get: async (c, id, [n]) => { + const row = await readableCollection(c, id); + return json({ revision: revisionView(await findRevision(c, id, n === "latest" ? row.latest_revision_number : Number(n))) }); + }, + }, + { + pattern: /^members$/, + post: async (c, id) => { + const row = await ownedCollection(c, id); + const b = await readBody(c.request, ["expectedRevision", "add", "remove"]); + const expected = requireRevisionNumber(b.expectedRevision); + const latest = revisionView(await findRevision(c, id, row.latest_revision_number)); + const content = await prepareContent(editMembers(latest.members, b.add, b.remove), latest.config); + return json({ revision: revisionView(await appendRevision(c, row, expected, content)) }, 201); + }, + }, + { + pattern: /^clone$/, + post: async (c, id) => { + const source = await readableCollection(c, id); + const b = await readBody(c.request, ["slug", "name", "visibility", "revisionNumber"]); + const number = b.revisionNumber === undefined ? source.latest_revision_number : requireRevisionNumber(b.revisionNumber, "revisionNumber"); + const from = revisionView(await findRevision(c, id, number)); + const created = await insertCollection( + c, + { slug: requireSlug(b.slug ?? source.slug), name: requireName(b.name, source.name), visibility: requireVisibility(b.visibility), clonedFrom: from.id }, + await prepareContent(from.members, from.config), + ); + return json({ collection: collectionView(created.collection, c.userId), revision: revisionView(created.revision) }, 201); + }, + }, +]; + +const methodNotAllowed = (route: RouteDef) => { + const allow = (["get", "post", "patch", "delete"] as const).filter((m) => route[m] !== undefined).map((m) => m.toUpperCase()).join(", "); + return json({ error: { code: "method_not_allowed", message: `use ${allow}` } }, 405); +}; + +/** Serves `/api/v1/collections` (list own, create). */ +export function serveCollectionIndex(request: Request, env: CollectionEnv, now?: () => Date): Promise { + const write = request.method === "POST"; + return serveCollections( + request, + env, + write ? "write" : "read", + async (c) => { + if (!write) { + if (request.method !== "GET" && request.method !== "HEAD") return json({ error: { code: "method_not_allowed", message: "use GET, POST" } }, 405); + if (c.userId === null) throw releaseError(401, "auth.required", "sign in, or send Authorization: Bearer "); + return json({ collections: (await listOwned(c)).map((row) => collectionView(row, c.userId)) }); + } + const b = await readBody(request, ["slug", "name", "visibility", "members", "config"]); + const created = await insertCollection( + c, + { slug: requireSlug(b.slug), name: requireName(b.name, typeof b.slug === "string" ? b.slug : undefined), visibility: requireVisibility(b.visibility), clonedFrom: null }, + await prepareContent(b.members, b.config), + ); + return json({ collection: collectionView(created.collection, c.userId), revision: revisionView(created.revision) }, 201); + }, + now, + ); +} + +/** Serves `/api/v1/collections/:id[/]`. */ +export function serveCollectionRequest(request: Request, env: CollectionEnv, params: Record, now?: () => Date): Promise { + const sub = params["*"] ?? ""; + const route = ROUTES.find((r) => r.pattern.test(sub)); + const method = (request.method === "HEAD" ? "GET" : request.method).toLowerCase() as "get" | "post" | "patch" | "delete"; + return serveCollections( + request, + env, + method === "get" ? "read" : "write", + async (c) => { + const id = params.id ?? ""; + if (route === undefined || !ID.test(id)) return notFoundResponse(); + const handler = route[method]; + if (handler === undefined) return methodNotAllowed(route); + return handler(c, id, route.pattern.exec(sub)!.slice(1)); + }, + now, + ); +} diff --git a/apps/web/app/lib/collections/collection-store.ts b/apps/web/app/lib/collections/collection-store.ts new file mode 100644 index 0000000..4f04d07 --- /dev/null +++ b/apps/web/app/lib/collections/collection-store.ts @@ -0,0 +1,191 @@ +/** + * D1 access of collections. A revision write is one batch: insert revision + * `n + 1`, then move `latest_revision_number` from `n` to `n + 1`. The unique + * `(collection_id, revision_number)` index makes two writers of the same `n` + * collide, and the batch is atomic, so the loser writes nothing. Revisions + * are never updated (the `collection_revisions_immutable` trigger aborts it). + */ +import { collectionRevisionDigest, sortCollectionMembers, validateCollectionContent } from "@skillx/contracts"; +import type { CollectionConfig, CollectionMember } from "@skillx/contracts"; +import { releaseError } from "~/lib/releases/release-http"; +import { CollectionValidationError, collectionNotFound } from "./collection-http"; +import type { CollectionCaller } from "./collection-http"; + +export interface CollectionRow { + id: string; + owner_id: string; + slug: string; + name: string; + visibility: string; + latest_revision_number: number; + cloned_from_revision_id: string | null; + created_at: number; + updated_at: number; +} + +export interface CollectionRevisionRow { + id: string; + collection_id: string; + revision_number: number; + members_json: string; + config_json: string | null; + revision_digest: string | null; + created_at: number; +} + +export const newId = (prefix: string) => `${prefix}_${crypto.randomUUID()}`; +const iso = (ms: number) => new Date(ms).toISOString(); + +export function collectionView(row: CollectionRow, userId: string | null) { + return { + id: row.id, + slug: row.slug, + name: row.name, + visibility: row.visibility, + owned: row.owner_id === userId, + latestRevisionNumber: row.latest_revision_number, + clonedFromRevisionId: row.cloned_from_revision_id, + createdAt: iso(row.created_at), + updatedAt: iso(row.updated_at), + }; +} + +export function revisionView(row: CollectionRevisionRow) { + return { + id: row.id, + collectionId: row.collection_id, + revisionNumber: row.revision_number, + revisionDigest: row.revision_digest, + members: JSON.parse(row.members_json) as CollectionMember[], + config: (row.config_json === null ? {} : JSON.parse(row.config_json)) as CollectionConfig, + createdAt: iso(row.created_at), + }; +} + +const db = (c: CollectionCaller) => c.env.DB; + +/** The collection when the caller may read it (owner, or not private); otherwise the 404 of an unknown one. */ +export async function readableCollection(c: CollectionCaller, id: string): Promise { + const row = await db(c).prepare("SELECT * FROM collections WHERE id = ?").bind(id).first(); + if (row === null || (row.visibility === "private" && row.owner_id !== c.userId)) throw collectionNotFound(); + return row; +} + +/** The collection when the caller owns it; anyone else gets 404, even for a public one. */ +export async function ownedCollection(c: CollectionCaller, id: string): Promise { + const row = await readableCollection(c, id); + if (row.owner_id !== c.userId) throw collectionNotFound(); + return row; +} + +export async function findRevision(c: CollectionCaller, collectionId: string, number: number): Promise { + const row = await db(c) + .prepare("SELECT * FROM collection_revisions WHERE collection_id = ? AND revision_number = ?") + .bind(collectionId, number) + .first(); + if (row === null) throw collectionNotFound(); + return row; +} + +export async function listRevisions(c: CollectionCaller, collectionId: string): Promise { + const res = await db(c).prepare("SELECT * FROM collection_revisions WHERE collection_id = ? ORDER BY revision_number DESC LIMIT 100").bind(collectionId).all(); + return res.results; +} + +/** Validated, sorted content of a new revision; 422 with the issue list otherwise. */ +export async function prepareContent(members: unknown, config: unknown) { + const result = validateCollectionContent({ members, config }); + if (!result.valid) throw new CollectionValidationError(result.issues.map((i) => ({ path: i.path, code: i.code }))); + const content = { members: sortCollectionMembers((members ?? []) as CollectionMember[]), config: (config ?? {}) as CollectionConfig }; + return { ...content, digest: await collectionRevisionDigest(content) }; +} + +type Content = Awaited>; + +function insertRevision(c: CollectionCaller, collectionId: string, number: number, content: Content): { row: CollectionRevisionRow; statement: D1PreparedStatement } { + const row: CollectionRevisionRow = { + id: newId("colrev"), + collection_id: collectionId, + revision_number: number, + members_json: JSON.stringify(content.members), + config_json: JSON.stringify(content.config), + revision_digest: content.digest, + created_at: c.now.getTime(), + }; + const statement = db(c) + .prepare("INSERT INTO collection_revisions (id, collection_id, revision_number, members_json, config_json, revision_digest, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(row.id, row.collection_id, row.revision_number, row.members_json, row.config_json, row.revision_digest, row.created_at); + return { row, statement }; +} + +const isUniqueViolation = (error: unknown) => error instanceof Error && /UNIQUE constraint failed/i.test(error.message); + +/** Creates a collection with revision 1. A slug the owner already uses is 409 `collection.slug_exists`. */ +export async function insertCollection( + c: CollectionCaller, + fields: { slug: string; name: string; visibility: string; clonedFrom: string | null }, + content: Content, +): Promise<{ collection: CollectionRow; revision: CollectionRevisionRow }> { + const now = c.now.getTime(); + const collection: CollectionRow = { + id: newId("col"), + owner_id: c.userId!, + slug: fields.slug, + name: fields.name, + visibility: fields.visibility, + latest_revision_number: 1, + cloned_from_revision_id: fields.clonedFrom, + created_at: now, + updated_at: now, + }; + const revision = insertRevision(c, collection.id, 1, content); + try { + await db(c).batch([ + db(c) + .prepare("INSERT INTO collections (id, owner_id, slug, name, visibility, latest_revision_number, cloned_from_revision_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)") + .bind(collection.id, collection.owner_id, collection.slug, collection.name, collection.visibility, collection.cloned_from_revision_id, now, now), + revision.statement, + ]); + } catch (error) { + if (isUniqueViolation(error)) throw releaseError(409, "collection.slug_exists", `you already have a collection named ${fields.slug}`); + throw error; + } + return { collection, revision: revision.row }; +} + +/** Appends revision `expected + 1`; a stale `expected` is 409 `collection.revision_conflict`. */ +export async function appendRevision(c: CollectionCaller, row: CollectionRow, expected: number, content: Content): Promise { + const conflict = () => releaseError(409, "collection.revision_conflict", `the latest revision is ${row.latest_revision_number}, not ${expected}`); + if (row.latest_revision_number !== expected) throw conflict(); + const next = expected + 1; + const revision = insertRevision(c, row.id, next, content); + try { + await db(c).batch([ + revision.statement, + db(c) + .prepare("UPDATE collections SET latest_revision_number = ?, updated_at = ? WHERE id = ? AND latest_revision_number = ?") + .bind(next, c.now.getTime(), row.id, expected), + ]); + } catch (error) { + if (isUniqueViolation(error)) throw conflict(); + throw error; + } + return revision.row; +} + +export async function updateMetadata(c: CollectionCaller, row: CollectionRow, name: string, visibility: string): Promise { + await db(c).prepare("UPDATE collections SET name = ?, visibility = ?, updated_at = ? WHERE id = ?").bind(name, visibility, c.now.getTime(), row.id).run(); + return { ...row, name, visibility, updated_at: c.now.getTime() }; +} + +export async function deleteCollection(c: CollectionCaller, row: CollectionRow): Promise { + await db(c).batch([ + db(c).prepare("DELETE FROM collection_revisions WHERE collection_id = ?").bind(row.id), + db(c).prepare("DELETE FROM collections WHERE id = ?").bind(row.id), + ]); +} + +export async function listOwned(c: CollectionCaller): Promise { + const res = await db(c).prepare("SELECT * FROM collections WHERE owner_id = ? ORDER BY updated_at DESC LIMIT 200").bind(c.userId).all(); + return res.results; +} diff --git a/apps/web/app/lib/db/collections-schema.ts b/apps/web/app/lib/db/collections-schema.ts index 78d92d6..347ff88 100644 --- a/apps/web/app/lib/db/collections-schema.ts +++ b/apps/web/app/lib/db/collections-schema.ts @@ -11,6 +11,8 @@ export const collections = sqliteTable( name: text("name").notNull(), visibility: text("visibility").notNull().default("private"), // private | unlisted | public latest_revision_number: integer("latest_revision_number").notNull().default(0), + // Lineage of a clone: the source revision it was copied from (no FK: the source may be deleted). + cloned_from_revision_id: text("cloned_from_revision_id"), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, @@ -29,6 +31,7 @@ export const collectionRevisions = sqliteTable( revision_number: integer("revision_number").notNull(), members_json: text("members_json").notNull(), // JSON: [{ packageName, version?, digest?, optional? }] config_json: text("config_json"), // JSON: non-secret configuration + revision_digest: text("revision_digest"), // sha256 of canonical {config, members} created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 9d26410..c916c04 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -52,5 +52,8 @@ export default [ route("api/v1/keys", "routes/api.v1.signing-keys.ts"), route("api/v1/kits/:scope/:name", "routes/api.v1.kits.ts", { id: "personal-kit" }), route("api/v1/kits/:scope/:name/*", "routes/api.v1.kits.ts", { id: "personal-kit-sub" }), + route("api/v1/collections", "routes/api.v1.collections.ts", { id: "collections" }), + route("api/v1/collections/:id", "routes/api.v1.collections.ts", { id: "collection" }), + route("api/v1/collections/:id/*", "routes/api.v1.collections.ts", { id: "collection-sub" }), route("*", "routes/$.tsx"), ] satisfies RouteConfig; diff --git a/apps/web/app/routes/api.v1.collections.ts b/apps/web/app/routes/api.v1.collections.ts new file mode 100644 index 0000000..ec29528 --- /dev/null +++ b/apps/web/app/routes/api.v1.collections.ts @@ -0,0 +1,19 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { serveCollectionIndex, serveCollectionRequest } from "~/lib/collections/collection-router"; + +/** + * Collections API, mounted on `/api/v1/collections`, `/api/v1/collections/:id` + * and `/api/v1/collections/:id/*` (see docs/api-reference.md#collections). + * Answers 404 to everything while `SKILLX_COLLECTIONS_API` is off. + */ +function serve(request: Request, env: Env, params: Record) { + return params.id === undefined ? serveCollectionIndex(request, env) : serveCollectionRequest(request, env, params); +} + +export async function loader({ request, params, context }: LoaderFunctionArgs) { + return serve(request, context.cloudflare.env, params); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return serve(request, context.cloudflare.env, params); +} diff --git a/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql b/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql new file mode 100644 index 0000000..6c356f4 --- /dev/null +++ b/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql @@ -0,0 +1,20 @@ +-- Collections API (issue #35): revision digests, clone lineage and immutable revisions. +-- +-- Strictly additive: two nullable columns and one trigger. Existing rows keep NULL in both +-- columns (no backfill; revisions created before this migration have no digest). The +-- trigger aborts every UPDATE of a collection revision, so a stored revision can never be +-- rewritten; revisions are still removed by the ON DELETE CASCADE of their collection. +-- +-- Rollback (take a D1 time-travel bookmark first). SQLite >= 3.35 drops columns in place; +-- the last statement removes the wrangler bookkeeping row so a later apply re-runs 0019: +-- DROP TRIGGER IF EXISTS collection_revisions_immutable; +-- ALTER TABLE `collection_revisions` DROP COLUMN `revision_digest`; +-- ALTER TABLE `collections` DROP COLUMN `cloned_from_revision_id`; +-- DELETE FROM d1_migrations WHERE name = '0019_collection_revision_lineage.sql'; + +ALTER TABLE `collection_revisions` ADD `revision_digest` text;--> statement-breakpoint +ALTER TABLE `collections` ADD `cloned_from_revision_id` text;--> statement-breakpoint +CREATE TRIGGER `collection_revisions_immutable` BEFORE UPDATE ON `collection_revisions` +BEGIN + SELECT RAISE(ABORT, 'collection revisions are immutable'); +END; diff --git a/apps/web/drizzle/migrations/meta/0019_snapshot.json b/apps/web/drizzle/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..c0a8f2c --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0019_snapshot.json @@ -0,0 +1,3042 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "597309b0-cc14-4448-96ff-71366d81d623", + "prevId": "0d0cd266-cecf-468c-8fc4-b07b44ced5ce", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_aliases": { + "name": "skill_aliases", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_aliases_skill": { + "name": "idx_skill_aliases_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_aliases_skill_id_skills_id_fk": { + "name": "skill_aliases_skill_id_skills_id_fk", + "tableFrom": "skill_aliases", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_repo": { + "name": "source_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + }, + "idx_skills_source_identity": { + "name": "idx_skills_source_identity", + "columns": [ + "source_repo", + "source_path" + ], + "isUnique": true, + "where": "source_repo IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_manifest_key": { + "name": "signed_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_sequence": { + "name": "status_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_issued_at": { + "name": "status_issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exec_summary_json": { + "name": "exec_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + }, + "idx_package_releases_digest": { + "name": "idx_package_releases_digest", + "columns": [ + "release_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_state_audit": { + "name": "release_state_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_state_audit_subject": { + "name": "idx_release_state_audit_subject", + "columns": [ + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_token_exchanges": { + "name": "oidc_token_exchanges", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trusted_publisher_id": { + "name": "trusted_publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_ref": { + "name": "workflow_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oidc_token_exchanges_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_oidc_token_exchanges_trusted": { + "name": "idx_oidc_token_exchanges_trusted", + "columns": [ + "trusted_publisher_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_publish_idempotency": { + "name": "release_publish_idempotency", + "columns": { + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_publish_idempotency_expires": { + "name": "idx_release_publish_idempotency_expires", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "release_publish_idempotency_publisher_id_idempotency_key_pk": { + "columns": [ + "publisher_id", + "idempotency_key" + ], + "name": "release_publish_idempotency_publisher_id_idempotency_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_reviews": { + "name": "release_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_reviews_release": { + "name": "idx_release_reviews_release", + "columns": [ + "release_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_publishers": { + "name": "trusted_publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_trusted_publishers_publisher": { + "name": "idx_trusted_publishers_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloned_from_revision_id": { + "name": "cloned_from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_audit": { + "name": "personal_kit_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_audit_kit": { + "name": "idx_personal_kit_audit_kit", + "columns": [ + "kit_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_conflicts": { + "name": "personal_kit_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_json": { + "name": "report_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_json": { + "name": "resolution_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_revision_id": { + "name": "resolved_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reeval_required": { + "name": "reeval_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_conflicts_kit": { + "name": "idx_personal_kit_conflicts_kit", + "columns": [ + "kit_id", + "status" + ], + "isUnique": false + }, + "idx_personal_kit_conflicts_idempotency": { + "name": "idx_personal_kit_conflicts_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "personal_kit_conflicts_kit_id_personal_kits_id_fk": { + "name": "personal_kit_conflicts_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_conflicts", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_proposals": { + "name": "personal_kit_proposals", + "columns": { + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_permissions_json": { + "name": "declared_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_permissions_json": { + "name": "required_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scan_json": { + "name": "scan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_proposals_kit": { + "name": "idx_personal_kit_proposals_kit", + "columns": [ + "kit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk": { + "name": "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk", + "tableFrom": "personal_kit_proposals", + "tableTo": "personal_kit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_revisions": { + "name": "personal_kit_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_json": { + "name": "document_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_json": { + "name": "approval_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated_at": { + "name": "activated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_revisions_number": { + "name": "idx_personal_kit_revisions_number", + "columns": [ + "kit_id", + "revision_number" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_idempotency": { + "name": "idx_personal_kit_revisions_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_digest": { + "name": "idx_personal_kit_revisions_digest", + "columns": [ + "kit_id", + "revision_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_revisions_kit_id_personal_kits_id_fk": { + "name": "personal_kit_revisions_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_revisions", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kits": { + "name": "personal_kits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "effective_revision_id": { + "name": "effective_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kits_name": { + "name": "idx_personal_kits_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_personal_kits_owner": { + "name": "idx_personal_kits_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kits_publisher_id_publishers_id_fk": { + "name": "personal_kits_publisher_id_publishers_id_fk", + "tableFrom": "personal_kits", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index 883ae2e..f194f14 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1792412883490, "tag": "0018_registry_publishing", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1792412951303, + "tag": "0019_collection_revision_lineage", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/tests/collections/collections-api.test.ts b/apps/web/tests/collections/collections-api.test.ts new file mode 100644 index 0000000..71b3bd8 --- /dev/null +++ b/apps/web/tests/collections/collections-api.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { releasePublishScope } from "~/lib/auth/api-key-scopes"; +import { action, loader } from "~/routes/api.v1.collections"; +import { FakeRateLimiter } from "../releases/fake-rate-limiter"; +import { createHarness } from "../releases/release-harness"; + +type Harness = Awaited>; +let h: Harness; +let ownerKey: string; +let otherKey: string; +let scopedKey: string; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +beforeEach(async () => { + h = await createHarness(); + ownerKey = await h.createKey("user-a", null); + otherKey = await h.createKey("user-b", null); + scopedKey = await h.createKey("user-a", [releasePublishScope("alpha")]); +}); + +interface CallOptions { + key?: string | null; + env?: Record; + method?: string; + headers?: Record; +} + +async function call(path: string, body?: unknown, o: CallOptions = {}) { + const method = o.method ?? (body === undefined ? "GET" : "POST"); + const headers = new Headers({ "Content-Type": "application/json", ...o.headers }); + const key = o.key === undefined ? ownerKey : o.key; + if (key !== null) headers.set("Authorization", `Bearer ${key}`); + const [, , , , id, ...rest] = path.split("/"); + const params = { ...(id === undefined ? {} : { id }), ...(rest.length === 0 ? {} : { "*": rest.join("/") }) }; + const request = new Request(`https://skillx.test${path}`, { method, headers, body: method === "GET" ? undefined : JSON.stringify(body ?? {}) }); + const env = { + SKILLX_COLLECTIONS_API: "1", + DB: h.deps.db, + RELEASE_READ_LIMITER: new FakeRateLimiter(10_000), + RELEASE_WRITE_LIMITER: new FakeRateLimiter(10_000), + ADMIN_SECRET: "test-admin-secret-0123456789", + ...o.env, + }; + const args = { request, params, context: { cloudflare: { env } } } as never; + const res = method === "GET" ? await loader(args) : await action(args); + return { status: res.status, body: (await res.json()) as Record }; +} + +const create = (extra: Record = {}) => + call("/api/v1/collections", { slug: "starter", name: "Starter", members: [{ packageName: "@alpha/demo", version: "1.0.0" }], config: { scope: "user" }, ...extra }); + +describe("collections API", () => { + it("is 404 while the flag is off", async () => { + const res = await call("/api/v1/collections", undefined, { env: { SKILLX_COLLECTIONS_API: "0" } }); + expect(res).toEqual({ status: 404, body: { error: { code: "not_found", message: "Not found" } } }); + }); + + it("creates a private collection with an immutable, digested revision 1", async () => { + const res = await create(); + expect(res.status).toBe(201); + expect(res.body.collection).toMatchObject({ slug: "starter", visibility: "private", owned: true, latestRevisionNumber: 1, clonedFromRevisionId: null }); + expect(res.body.revision).toMatchObject({ revisionNumber: 1, members: [{ packageName: "@alpha/demo", version: "1.0.0" }], config: { scope: "user" } }); + expect(res.body.revision.revisionDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(() => h.d1.raw.prepare("UPDATE collection_revisions SET members_json = '[]'").run()).toThrow(/immutable/); + expect((await create()).body.error.code).toBe("collection.slug_exists"); + }); + + it("hides private collections from others and anonymous callers, shows unlisted ones", async () => { + const { id } = (await create()).body.collection; + expect((await call(`/api/v1/collections/${id}`, undefined, { key: otherKey })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}`, undefined, { key: null })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}`, { visibility: "unlisted" }, { method: "PATCH" })).body.collection.visibility).toBe("unlisted"); + const anon = await call(`/api/v1/collections/${id}/revisions/latest`, undefined, { key: null }); + expect(anon.status).toBe(200); + expect(anon.body.revision.revisionNumber).toBe(1); + expect((await call(`/api/v1/collections/${id}`, { name: "Mine now" }, { method: "PATCH", key: otherKey })).status).toBe(404); + }); + + it("adds and removes members as new revisions with compare-and-set", async () => { + const { id } = (await create()).body.collection; + const added = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 1, add: [{ packageName: "@alpha/zed", version: "2.0.0", digest: DIGEST }] }); + expect(added.status).toBe(201); + expect(added.body.revision.members.map((m: { packageName: string }) => m.packageName)).toEqual(["@alpha/demo", "@alpha/zed"]); + const stale = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 1, remove: ["@alpha/demo"] }); + expect(stale).toMatchObject({ status: 409, body: { error: { code: "collection.revision_conflict" } } }); + const removed = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 2, remove: ["@alpha/demo"] }); + expect(removed.body.revision).toMatchObject({ revisionNumber: 3, members: [{ packageName: "@alpha/zed" }] }); + expect((await call(`/api/v1/collections/${id}/members`, { expectedRevision: 3, remove: ["@alpha/nope"] })).body.error.code).toBe("collection.member_missing"); + const revisions = await call(`/api/v1/collections/${id}/revisions`); + expect(revisions.body.revisions.map((r: { revisionNumber: number }) => r.revisionNumber)).toEqual([3, 2, 1]); + expect((await call(`/api/v1/collections/${id}/revisions/1`)).body.revision.members).toHaveLength(1); + }); + + it("replaces content with a full revision and refuses secrets and absolute paths", async () => { + const { id } = (await create()).body.collection; + const ok = await call(`/api/v1/collections/${id}/revisions`, { expectedRevision: 1, members: [], config: {} }); + expect(ok.body.revision).toMatchObject({ revisionNumber: 2, members: [] }); + const leak = await call(`/api/v1/collections/${id}/revisions`, { expectedRevision: 2, members: [], config: { token: "ghp_0123456789abcdefghijklmnopqrstuvwxyz", dir: "/home/alice/skills" } }); + expect(leak.status).toBe(422); + expect(leak.body.error.issues).toEqual(expect.arrayContaining([{ path: "config.token", code: "sync.secret-key" }, { path: "config.dir", code: "sync.absolute-path" }])); + expect(JSON.stringify(leak.body)).not.toContain("ghp_"); + }); + + it("clones a readable collection into the caller's account with lineage", async () => { + const created = (await create({ visibility: "public" })).body; + const clone = await call(`/api/v1/collections/${created.collection.id}/clone`, { slug: "copy" }, { key: otherKey }); + expect(clone.status).toBe(201); + expect(clone.body.collection).toMatchObject({ slug: "copy", visibility: "private", owned: true, clonedFromRevisionId: created.revision.id }); + expect(clone.body.revision.revisionDigest).toBe(created.revision.revisionDigest); + const privateOne = (await create({ slug: "secret-list" })).body.collection; + expect((await call(`/api/v1/collections/${privateOne.id}/clone`, { slug: "x" }, { key: otherKey })).status).toBe(404); + }); + + it("deletes only for the owner, and lists only the caller's collections", async () => { + const { id } = (await create()).body.collection; + expect((await call(`/api/v1/collections/${id}`, undefined, { method: "DELETE", key: otherKey })).status).toBe(404); + expect((await call("/api/v1/collections", undefined, { key: otherKey })).body.collections).toEqual([]); + expect((await call(`/api/v1/collections/${id}`, undefined, { method: "DELETE" })).body).toEqual({ deleted: true, id }); + expect((await call(`/api/v1/collections/${id}`)).status).toBe(404); + expect(h.d1.raw.prepare("SELECT count(*) AS n FROM collection_revisions").get()).toEqual({ n: 0 }); + }); + + it("refuses scoped publish keys, the admin secret and anonymous writes", async () => { + expect((await create()).status).toBe(201); + expect((await call("/api/v1/collections", { slug: "x" }, { key: scopedKey })).status).toBe(401); + expect((await call("/api/v1/collections", { slug: "x" }, { key: null })).status).toBe(401); + expect((await call("/api/v1/collections", undefined, { key: null })).status).toBe(401); + const admin = await call("/api/v1/collections", { slug: "x" }, { key: null, headers: { "X-Admin-Secret": "test-admin-secret-0123456789" } }); + expect(admin).toMatchObject({ status: 403, body: { error: { code: "collection.owner_only" } } }); + }); + + it("validates bodies: unknown fields, bad slugs and member shapes", async () => { + expect((await call("/api/v1/collections", { slug: "Bad Slug" })).body.error.code).toBe("invalid_request"); + expect((await call("/api/v1/collections", { slug: "ok", extra: 1 })).body.error.code).toBe("invalid_request"); + const bad = await call("/api/v1/collections", { slug: "ok", members: [{ packageName: "nope" }] }); + expect(bad).toMatchObject({ status: 422, body: { error: { code: "collection.invalid" } } }); + }); +}); diff --git a/apps/web/tests/install/install-e2e.test.ts b/apps/web/tests/install/install-e2e.test.ts new file mode 100644 index 0000000..1c81fa0 --- /dev/null +++ b/apps/web/tests/install/install-e2e.test.ts @@ -0,0 +1,174 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { lockDigest } from "@skillx/contracts"; +import { installRefs, listInstalled, uninstallRef, updateInstalled } from "../../../../packages/cli/src/lib/install/install-operations"; +import { dropFromLockFile, readLockFile, writeLockFile } from "../../../../packages/cli/src/lib/install/lock-file"; +import { readReceipt, scanDir } from "../../../../packages/cli/src/lib/install/install-state"; +import { syncLocked } from "../../../../packages/cli/src/lib/install/sync-locked"; +import { BODY, skillText } from "../personal-kits/kit-harness"; +import { createInstallServer } from "./install-server"; + +// Each test installs signed releases on several temp machines; under a full parallel run that can pass 5s. +vi.setConfig({ testTimeout: 30_000 }); + +type Server = Awaited>; +let s: Server; + +const DEMO = { "SKILL.md": skillText(BODY), "references/guide.md": "guide v1\n" }; +const TOOLS = { "SKILL.md": skillText("# Tools\n", "name: tools\ndescription: Tools"), "scripts/run.sh": "echo run\n" }; + +beforeEach(async () => { + s = await createInstallServer(); + await s.k.publish("@alpha/demo", "1.0.0", DEMO); + await s.k.publish("@alpha/tools", "1.0.0", TOOLS); +}); +afterEach(() => s.cleanup()); + +/** Stores and activates a kit revision: demo with a personal overlay, tools as published. */ +async function activeKit() { + const mine = { ...DEMO, "SKILL.md": skillText(BODY.replace("three", "THREE (mine)")) }; + const document = s.k.doc([await s.k.entry("@alpha/demo", "1.0.0", mine), await s.k.entry("@alpha/tools", "1.0.0")]); + const rev = (await s.k.call("revisions", { expectedGeneration: 0, idempotencyKey: s.k.idem(), document })).body.revision; + const act = await s.k.call("activate", { expectedGeneration: 1, revisionId: rev.id, approval: { revisionDigest: rev.digest } }); + expect(act.status).toBe(200); + return { rev, mine }; +} + +const tree = (dir: string) => scanDir(dir).map((f) => `${f.path} ${f.sha256}`); + +describe("installer end to end", () => { + it("installs a kit revision on machine A and restores identical digests on machine B from the lock", async () => { + const { rev, mine } = await activeKit(); + const a = s.machine("a"); + const ctxA = a.context(); + const outcome = await installRefs(ctxA, ["kit:@alpha/my-kit"]); + expect(outcome.lock.source).toMatchObject({ kind: "kit", kit: "@alpha/my-kit", revisionId: rev.id, revisionDigest: rev.digest }); + expect(outcome.lock.packages.map((p) => [p.alias, p.overlayDigests.length])).toEqual([["demo", 1], ["tools", 0]]); + const demoA = join(ctxA.skillsDir, "demo"); + expect(ctxA.skillsDir).toBe(join(a.root, ".claude", "skills")); + expect(readFileSync(join(demoA, "SKILL.md"), "utf8")).toBe(mine["SKILL.md"]); + + const lockPath = writeLockFile(join(a.root, "skillx.lock.json"), outcome.lock); + expect(readFileSync(lockPath, "utf8")).not.toContain(a.root); + + const b = s.machine("b"); + const ctxB = b.context(); + const restored = await syncLocked(ctxB, readLockFile(lockPath)); + expect(restored.lockDigest).toBe(await lockDigest(outcome.lock)); + for (const alias of ["demo", "tools"]) { + expect(tree(join(ctxB.skillsDir, alias))).toEqual(tree(join(ctxA.skillsDir, alias))); + expect(readReceipt(ctxB.home, "claude-code", alias)!.compositionDigest).toBe(readReceipt(ctxA.home, "claude-code", alias)!.compositionDigest); + } + // Rerunning a sync on a converged machine changes nothing. + const again = await syncLocked(ctxB, readLockFile(lockPath)); + expect(again.items[0]!.plan.actions.map((x) => x.action)).toEqual(["unchanged", "unchanged"]); + + // A third machine restores the kit revision straight from the server, into Codex. + const c = s.machine("c"); + const ctxC = c.context({ runtime: "codex" }); + await installRefs(ctxC, [`kit:@alpha/my-kit#${rev.id}`]); + expect(ctxC.skillsDir).toBe(join(c.root, ".codex", "skills")); + expect(tree(join(ctxC.skillsDir, "demo"))).toEqual(tree(demoA)); + }); + + it("refuses drift: an edited lock, and local edits unless forced (which backs them up)", async () => { + await activeKit(); + const a = s.machine("a"); + const ctx = a.context(); + const { lock } = await installRefs(ctx, ["kit:@alpha/my-kit"]); + const tampered = { ...lock, packages: lock.packages.map((p) => (p.alias === "tools" ? { ...p, alias: "tools2" } : p)) }; + await expect(syncLocked(ctx, tampered)).rejects.toMatchObject({ code: "conflict" }); + + const skill = join(ctx.skillsDir, "tools", "SKILL.md"); + writeFileSync(skill, "my local edit\n"); + await expect(syncLocked(ctx, lock)).rejects.toMatchObject({ code: "conflict", details: { conflicts: [expect.objectContaining({ alias: "tools", reason: "local_modified" })] } }); + expect(readFileSync(skill, "utf8")).toBe("my local edit\n"); + + const forced = await syncLocked(a.context({ force: true }), lock); + const backupDir = forced.applied[0]!.changed.find((c) => c.alias === "tools")!.backupDir!; + expect(readFileSync(join(backupDir, "SKILL.md"), "utf8")).toBe("my local edit\n"); + expect(readFileSync(skill, "utf8")).toBe(TOOLS["SKILL.md"]); + }); + + it("verifies before writing: a tampered file or an untrusted signing key installs nothing", async () => { + const a = s.machine("a"); + s.setRewrite(async (path, res) => (path.endsWith("/files/scripts/run.sh") ? new Response("echo pwned\n", { status: 200 }) : res)); + await expect(installRefs(a.context(), ["@alpha/tools"])).rejects.toMatchObject({ code: "integrity_error" }); + s.setRewrite(null); + await expect(installRefs(a.context({ keys: new Map() }), ["@alpha/tools"])).rejects.toMatchObject({ code: "integrity_error" }); + expect(existsSync(join(a.context().skillsDir, "tools"))).toBe(false); + expect(listInstalled(a.context()).skills).toEqual([]); + }); + + it("installs a collection revision, pinned and latest members, into Codex", async () => { + await s.k.publish("@alpha/tools", "1.1.0", { ...TOOLS, "scripts/run.sh": "echo run 1.1\n" }); + const created = await s.serverFetch("https://skillx.test/api/v1/collections", { + method: "POST", + headers: { Authorization: `Bearer ${s.k.ownerKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ slug: "starter", members: [{ packageName: "@alpha/demo", version: "1.0.0" }, { packageName: "@alpha/tools" }] }), + }); + const { collection } = (await created.json()) as { collection: { id: string } }; + const c = s.machine("c"); + const ctx = c.context({ runtime: "codex" }); + const outcome = await installRefs(ctx, [`collection:${collection.id}`]); + expect(outcome.lock.source).toMatchObject({ kind: "collection", collectionId: collection.id, revisionNumber: 1 }); + expect(outcome.lock.packages.map((p) => `${p.package}@${p.version}`)).toEqual(["@alpha/demo@1.0.0", "@alpha/tools@1.1.0"]); + expect(readFileSync(join(c.root, ".codex", "skills", "tools", "scripts", "run.sh"), "utf8")).toBe("echo run 1.1\n"); + }); + + it("ref-counts shared skills and uninstalls with ownership proof, keeping edited and unknown files", async () => { + await activeKit(); + const a = s.machine("a"); + const ctx = a.context(); + await installRefs(ctx, ["kit:@alpha/my-kit"]); + await installRefs(ctx, ["@alpha/tools@1.0.0"]); + expect(readReceipt(ctx.home, "claude-code", "tools")!.requiredBy).toEqual(["kit:@alpha/my-kit", "packages:@alpha/tools"]); + + const [kitGone] = await uninstallRef(ctx, ["kit:@alpha/my-kit"]); + expect(kitGone!.removed.map((r) => r.alias)).toEqual(["demo"]); + expect(existsSync(join(ctx.skillsDir, "demo"))).toBe(false); + expect(readReceipt(ctx.home, "claude-code", "tools")!.requiredBy).toEqual(["packages:@alpha/tools"]); + + const tools = join(ctx.skillsDir, "tools"); + writeFileSync(join(tools, "SKILL.md"), "edited\n"); + writeFileSync(join(tools, "notes.txt"), "mine\n"); + const [gone] = await uninstallRef(ctx, ["@alpha/tools"]); + expect(gone!.removed[0]).toMatchObject({ alias: "tools", removed: ["scripts/run.sh"], keptModified: ["SKILL.md"], keptUnknown: ["notes.txt"] }); + expect(scanDir(tools).map((f) => f.path)).toEqual(["SKILL.md", "notes.txt"]); + expect(readReceipt(ctx.home, "claude-code", "tools")).toBeNull(); + await expect(uninstallRef(ctx, ["@alpha/tools"])).rejects.toMatchObject({ code: "not_found" }); + }); + + it("gives each package of a multi-package install or a packages-lock sync its own owner", async () => { + const a = s.machine("a"); + const ctx = a.context(); + const installed = await installRefs(ctx, ["@alpha/demo@1.0.0", "@alpha/tools"]); + for (const alias of ["demo", "tools"]) expect(readReceipt(ctx.home, "claude-code", alias)!.requiredBy).toEqual([`packages:@alpha/${alias}`]); + + const b = s.machine("b").context(); + await syncLocked(b, installed.lock); + expect(readReceipt(b.home, "claude-code", "tools")!.requiredBy).toEqual(["packages:@alpha/tools"]); + const [updated] = await updateInstalled(b, ["@alpha/demo"]); + expect(updated!.items.map((i) => i.owner)).toEqual(["packages:@alpha/demo"]); + const gone = await uninstallRef(b, ["@alpha/demo", "@alpha/tools", "@alpha/demo"]); + expect(gone.map((g) => g.owner)).toEqual(["packages:@alpha/demo", "packages:@alpha/tools"]); + expect(scanDir(b.skillsDir)).toEqual([]); + }); + + it("keeps the packages lock in step when a multi-package uninstall stops part way", async () => { + const a = s.machine("a"); + const ctx = a.context(); + const installed = await installRefs(ctx, ["@alpha/demo@1.0.0", "@alpha/tools"]); + const lockPath = join(a.root, "skillx.lock.json"); + writeLockFile(lockPath, installed.lock); + const failAfterDemo = (owner: string) => { + dropFromLockFile(lockPath, owner.slice("packages:".length)); + if (owner === "packages:@alpha/demo") throw new Error("stopped"); + }; + await expect(uninstallRef(ctx, ["@alpha/demo", "@alpha/tools"], failAfterDemo)).rejects.toThrow("stopped"); + expect(readLockFile(lockPath).packages.map((p) => p.alias)).toEqual(["tools"]); + expect(readReceipt(ctx.home, "claude-code", "demo")).toBeNull(); + expect(readReceipt(ctx.home, "claude-code", "tools")!.requiredBy).toEqual(["packages:@alpha/tools"]); + }); +}); diff --git a/apps/web/tests/install/install-hardening.test.ts b/apps/web/tests/install/install-hardening.test.ts new file mode 100644 index 0000000..7649f47 --- /dev/null +++ b/apps/web/tests/install/install-hardening.test.ts @@ -0,0 +1,238 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CrashPoint } from "../../../../packages/cli/src/lib/install/apply-transaction"; +import { withInstallLock } from "../../../../packages/cli/src/lib/install/install-context"; +import { installRefs } from "../../../../packages/cli/src/lib/install/install-operations"; +import { readReceipt, scanDir, writeReceipt } from "../../../../packages/cli/src/lib/install/install-state"; +import { handleMessage } from "../../../../packages/cli/src/lib/install/local-mcp"; +import { skillText } from "../personal-kits/kit-harness"; +import { createInstallServer } from "./install-server"; + +// Each test installs signed releases on several temp machines; under a full parallel run that can pass 5s. +vi.setConfig({ testTimeout: 30_000 }); + +type Server = Awaited>; +let s: Server; + +const V1 = { "SKILL.md": skillText("# Demo v1\n"), "old-only.md": "v1 file\n" }; +const V2 = { "SKILL.md": skillText("# Demo v2\n"), "new-only.md": "v2 file\n" }; + +beforeEach(async () => { + s = await createInstallServer(); + await s.k.publish("@alpha/demo", "1.0.0", V1); + await s.k.publish("@alpha/demo", "2.0.0", V2); +}); +afterEach(() => s.cleanup()); + +const paths = (dir: string) => scanDir(dir).map((f) => f.path); +const at = (point: CrashPoint, run: (dir: string) => void) => (p: CrashPoint, alias: string) => { + if (p === point) run(alias); +}; + +describe("files changed between plan and swap", () => { + it("refuses the swap when a file appears or changes after planning", async () => { + const m = s.machine("drift"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + const dir = join(ctx.skillsDir, "demo"); + + for (const edit of [() => writeFileSync(join(dir, "notes.md"), "mine\n"), () => writeFileSync(join(dir, "old-only.md"), "edited\n")]) { + const racing = m.context({ hooks: { at: at("staged", edit) } }); + await expect(installRefs(racing, ["@alpha/demo@2.0.0"])).rejects.toMatchObject({ + code: "conflict", + details: { conflicts: [expect.objectContaining({ alias: "demo", reason: "local_modified" })] }, + }); + expect(readReceipt(ctx.home, "claude-code", "demo")!.version).toBe("1.0.0"); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + } + expect(paths(dir)).toEqual(["SKILL.md", "notes.md", "old-only.md"]); + expect(readFileSync(join(dir, "notes.md"), "utf8")).toBe("mine\n"); + }); + + it("saves a file created after the final check to the backups instead of deleting it", async () => { + const m = s.machine("late"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + const late = m.context({ hooks: { at: at("journaled", () => writeFileSync(join(ctx.skillsDir, "demo", "late.md"), "late\n")) } }); + const outcome = await installRefs(late, ["@alpha/demo@2.0.0"]); + const change = outcome.applied[0]!.changed[0]!; + expect(change.displaced).toEqual(["late.md"]); + expect(change.backupDir).toContain(join(ctx.home, "backups")); + expect(readFileSync(join(change.backupDir!, "late.md"), "utf8")).toBe("late\n"); + expect(paths(join(ctx.skillsDir, "demo"))).toEqual(["SKILL.md", "new-only.md"]); + }); +}); + +describe("a skill directory held open by another program", () => { + const busy = () => Object.assign(new Error("resource busy"), { code: "EBUSY" }); + + it("retries a busy rename and completes", async () => { + const m = s.machine("busy-once"); + await installRefs(m.context(), ["@alpha/demo@1.0.0"]); + let failures = 2; + const ctx = m.context({ hooks: { rename: (from, to) => { if (failures-- > 0) throw busy(); renameSync(from, to); } } }); + await installRefs(ctx, ["@alpha/demo@2.0.0"]); + expect(paths(join(ctx.skillsDir, "demo"))).toEqual(["SKILL.md", "new-only.md"]); + }); + + it("gives up with a conflict and leaves the old tree whole", async () => { + const m = s.machine("busy-always"); + await installRefs(m.context(), ["@alpha/demo@1.0.0"]); + const ctx = m.context({ hooks: { rename: () => { throw busy(); } } }); + await expect(installRefs(ctx, ["@alpha/demo@2.0.0"])).rejects.toMatchObject({ code: "conflict" }); + const plain = m.context(); + expect(paths(join(plain.skillsDir, "demo"))).toEqual(["SKILL.md", "old-only.md"]); + expect(readReceipt(plain.home, "claude-code", "demo")!.version).toBe("1.0.0"); + expect(existsSync(join(plain.home, "journal", "claude-code", "demo.json"))).toBe(false); + expect(readdirSync(plain.stagingDir)).toEqual([]); + }); + + it("parks the old tree in the backups when something else takes its place", async () => { + const m = s.machine("reappears"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + const target = join(ctx.skillsDir, "demo"); + writeFileSync(join(target, "my-notes.md"), "mine\n"); + const racing = m.context({ + force: true, + hooks: { + rename: (from, to) => { + if (to !== target) return renameSync(from, to); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "editor.tmp"), "editor\n"); + throw Object.assign(new Error("not permitted"), { code: "EPERM" }); + }, + }, + }); + const error = await installRefs(racing, ["@alpha/demo@2.0.0"]).catch((e) => e); + expect(error).toMatchObject({ code: "conflict" }); + const [parked] = error.details.parked as Array<{ alias: string; savedTo: string }>; + expect(parked!.alias).toBe("demo"); + expect(paths(parked!.savedTo)).toEqual(["SKILL.md", "my-notes.md", "old-only.md"]); + expect(paths(target)).toEqual(["editor.tmp"]); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + expect(existsSync(join(ctx.home, "journal", "claude-code", "demo.json"))).toBe(false); + }); + + it("parks across volumes without following a junction in the old tree", async () => { + const m = s.machine("park-link"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + const target = join(ctx.skillsDir, "demo"); + const outside = join(m.root, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "private.md"), "private\n"); + const racing = m.context({ + hooks: { + rename: (from, to) => { + if (to.includes("previous-tree")) throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); + if (to !== target) return renameSync(from, to); + // The old tree is aside; a junction appears in it and something takes the skill path. + if (!existsSync(join(`${from}.old`, "refs"))) symlinkSync(outside, join(`${from}.old`, "refs"), "junction"); + mkdirSync(target, { recursive: true }); + throw Object.assign(new Error("not permitted"), { code: "EPERM" }); + }, + }, + }); + const error = await installRefs(racing, ["@alpha/demo@2.0.0"]).catch((e) => e); + expect(error).toMatchObject({ code: "conflict" }); + const [parked] = error.details.parked as Array<{ savedTo: string }>; + expect(paths(parked!.savedTo)).toEqual(["SKILL.md", "old-only.md", "refs.link"]); + expect(readFileSync(join(outside, "private.md"), "utf8")).toBe("private\n"); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + expect(existsSync(join(ctx.home, "journal", "claude-code", "demo.json"))).toBe(false); + }); +}); + +describe("links inside a skill directory", () => { + it("are kept while nothing is rewritten, and block an update even with --force", async () => { + const m = s.machine("inner-link"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + mkdirSync(join(m.root, "outside")); + writeFileSync(join(m.root, "outside", "private.md"), "private\n"); + symlinkSync(join(m.root, "outside"), join(ctx.skillsDir, "demo", "refs"), "junction"); + const same = await installRefs(ctx, ["@alpha/demo@1.0.0"]); + expect(same.items[0]!.plan.actions[0]).toMatchObject({ action: "unchanged", alias: "demo" }); + await expect(installRefs(m.context({ force: true }), ["@alpha/demo@2.0.0"])).rejects.toMatchObject({ + code: "conflict", + details: { conflicts: [expect.objectContaining({ alias: "demo", reason: "contains_link", files: ["refs"] })] }, + }); + expect(readReceipt(ctx.home, "claude-code", "demo")!.version).toBe("1.0.0"); + }); + + it("removes a link that appears after the final check without following it", async () => { + const m = s.machine("late-link"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + mkdirSync(join(m.root, "outside")); + writeFileSync(join(m.root, "outside", "private.md"), "private\n"); + const late = m.context({ hooks: { at: at("journaled", () => symlinkSync(join(m.root, "outside"), join(ctx.skillsDir, "demo", "refs"), "junction")) } }); + const change = (await installRefs(late, ["@alpha/demo@2.0.0"])).applied[0]!.changed[0]!; + expect(change.displaced).toEqual(["refs"]); + expect(readFileSync(join(change.backupDir!, "refs.link"), "utf8").trim()).not.toBe(""); + expect(readFileSync(join(m.root, "outside", "private.md"), "utf8")).toBe("private\n"); + expect(existsSync(join(ctx.home, "journal", "claude-code", "demo.json"))).toBe(false); + }); +}); + +describe("state left by a crashed run", () => { + it("drops owners that no longer exist and removes a skill nobody owns", async () => { + const m = s.machine("orphans"); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + const receipt = readReceipt(ctx.home, "claude-code", "demo")!; + writeReceipt(ctx.home, { ...receipt, alias: "ghost", requiredBy: ["kit:@alpha/gone"] }); + mkdirSync(join(ctx.skillsDir, "ghost")); + for (const f of receipt.files) writeFileSync(join(ctx.skillsDir, "ghost", f.path), readFileSync(join(ctx.skillsDir, "demo", f.path))); + writeReceipt(ctx.home, { ...receipt, requiredBy: [...receipt.requiredBy, "kit:@alpha/gone"] }); + + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + expect(readReceipt(ctx.home, "claude-code", "demo")!.requiredBy).toEqual(["packages:@alpha/demo"]); + expect(readReceipt(ctx.home, "claude-code", "ghost")).toBeNull(); + expect(existsSync(join(ctx.skillsDir, "ghost"))).toBe(false); + }); + + it("takes over a lock whose process is gone and refuses a live or unreadable one", async () => { + const ctx = s.machine("lock").context(); + const lockPath = join(ctx.home, "install.lock"); + mkdirSync(ctx.home, { recursive: true }); + writeFileSync(lockPath, "2147483646"); + await expect(withInstallLock(ctx, async () => "ran")).resolves.toBe("ran"); + expect(existsSync(lockPath)).toBe(false); + + for (const holder of [String(process.pid), "garbage", ""]) { + writeFileSync(lockPath, holder); + const refused = await withInstallLock(ctx, async () => "ran").catch((e) => e); + expect(refused).toMatchObject({ code: "conflict" }); + if (holder !== String(process.pid)) expect(refused.message).toContain(`remove ${lockPath}`); + expect(readFileSync(lockPath, "utf8")).toBe(holder); + } + }); +}); + +describe("compatibility is checked before content is fetched", () => { + it("refuses a malformed skillx.json without downloading the skill", async () => { + await s.k.publish("@alpha/broken", "1.0.0", { "SKILL.md": skillText("# Broken\n"), "skillx.json": "{ not json" }); + s.requests.length = 0; + await expect(installRefs(s.machine("broken").context(), ["@alpha/broken@1.0.0"])).rejects.toMatchObject({ code: "incompatible" }); + expect(s.requests.some((r) => r.endsWith("/files/SKILL.md"))).toBe(false); + }); +}); + +describe("local MCP apply", () => { + const call = (m: ReturnType, args: Record) => + handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "apply", arguments: args } }, (a) => m.context(a), "test") as Promise<{ + result: { isError: boolean; structuredContent: Record }; + }>; + + it("needs the digest of a plan the caller saw and never forces", async () => { + const m = s.machine("mcp-apply"); + for (const args of [{ confirm: true }, { confirm: true, planDigest: "sha256:x", force: true }]) { + const refused = await call(m, { source: "@alpha/demo@1.0.0", ...args }); + expect(refused.result.structuredContent.error.code).toBe("usage_error"); + } + expect(existsSync(join(m.context().skillsDir, "demo"))).toBe(false); + }); +}); diff --git a/apps/web/tests/install/install-recovery.test.ts b/apps/web/tests/install/install-recovery.test.ts new file mode 100644 index 0000000..621ebe5 --- /dev/null +++ b/apps/web/tests/install/install-recovery.test.ts @@ -0,0 +1,101 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CrashPoint } from "../../../../packages/cli/src/lib/install/apply-transaction"; +import { installRefs } from "../../../../packages/cli/src/lib/install/install-operations"; +import { readReceipt, scanDir } from "../../../../packages/cli/src/lib/install/install-state"; +import { handleMessage } from "../../../../packages/cli/src/lib/install/local-mcp"; +import { skillText } from "../personal-kits/kit-harness"; +import { createInstallServer } from "./install-server"; + +// Each test installs signed releases on several temp machines; under a full parallel run that can pass 5s. +vi.setConfig({ testTimeout: 30_000 }); + +type Server = Awaited>; +let s: Server; + +const V1 = { "SKILL.md": skillText("# Demo v1\n"), "old-only.md": "v1 file\n" }; +const V2 = { "SKILL.md": skillText("# Demo v2\n"), "new-only.md": "v2 file\n" }; + +beforeEach(async () => { + s = await createInstallServer(); + await s.k.publish("@alpha/demo", "1.0.0", V1); + await s.k.publish("@alpha/demo", "2.0.0", V2); +}); +afterEach(() => s.cleanup()); + +const POINTS: CrashPoint[] = ["staged", "journaled", "moved-old", "swapped", "receipt-written"]; +const paths = (dir: string) => scanDir(dir).map((f) => f.path); + +class Crash extends Error {} + +describe("interrupted installs converge on rerun", () => { + it.each(POINTS)("update killed at %s, then rerun", async (point) => { + const m = s.machine("crash"); + await installRefs(m.context(), ["@alpha/demo@1.0.0"]); + const crashing = m.context({ + hooks: { + at: (p) => { + if (p === point) throw new Crash(p); + }, + }, + }); + await expect(installRefs(crashing, ["@alpha/demo@2.0.0"])).rejects.toBeInstanceOf(Crash); + + // Whatever the crash point, the skill directory never holds a mix of versions; + // between the two renames it is briefly absent until the next run recovers it. + const ctx = m.context(); + const dir = join(ctx.skillsDir, "demo"); + const whole = [["SKILL.md", "old-only.md"], ["SKILL.md", "new-only.md"], ...(point === "moved-old" ? [[]] : [])]; + expect(whole).toContainEqual(paths(dir)); + + const rerun = await installRefs(ctx, ["@alpha/demo@2.0.0"]); + const recovered = [...rerun.recovered.rolledBack, ...rerun.recovered.rolledForward]; + expect(recovered).toEqual(point === "staged" ? [] : ["demo"]); + expect(paths(dir)).toEqual(["SKILL.md", "new-only.md"]); + expect(readReceipt(ctx.home, "claude-code", "demo")).toMatchObject({ version: "2.0.0", requiredBy: ["packages:@alpha/demo"] }); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + expect(existsSync(join(ctx.home, "journal", "claude-code", "demo.json"))).toBe(false); + }); + + it.each(POINTS)("first install killed at %s, then rerun", async (point) => { + const m = s.machine("fresh"); + const crashing = m.context({ hooks: { at: (p) => { if (p === point) throw new Crash(p); } } }); + await expect(installRefs(crashing, ["@alpha/demo@1.0.0"])).rejects.toBeInstanceOf(Crash); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + expect(paths(join(ctx.skillsDir, "demo"))).toEqual(["SKILL.md", "old-only.md"]); + expect(readReceipt(ctx.home, "claude-code", "demo")!.version).toBe("1.0.0"); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + }); +}); + +describe("local MCP", () => { + const call = (m: ReturnType, name: string, args: Record) => + handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, (a) => m.context(a), "test") as Promise<{ result: { isError: boolean; structuredContent: Record } }>; + + it("lists three tools and requires confirm: true to apply", async () => { + const m = s.machine("mcp"); + const list = (await handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/list" }, () => m.context(), "test")) as { result: { tools: Array<{ name: string }> } }; + expect(list.result.tools.map((t) => t.name)).toEqual(["inspect", "plan", "apply"]); + + const refused = await call(m, "apply", { source: "@alpha/demo@1.0.0" }); + expect(refused.result.isError).toBe(true); + expect(refused.result.structuredContent.error.code).toBe("usage_error"); + expect(existsSync(join(m.context().skillsDir, "demo"))).toBe(false); + + const plan = await call(m, "plan", { source: "@alpha/demo@1.0.0", runtime: "codex" }); + expect(plan.result.structuredContent.items[0].plan.actions[0]).toMatchObject({ action: "install", alias: "demo" }); + const digest = plan.result.structuredContent.planDigest as string; + + const stale = await call(m, "apply", { source: "@alpha/demo@2.0.0", runtime: "codex", confirm: true, planDigest: digest }); + expect(stale.result.structuredContent.error.code).toBe("conflict"); + + const applied = await call(m, "apply", { source: "@alpha/demo@1.0.0", runtime: "codex", confirm: true, planDigest: digest }); + expect(applied.result.isError).toBe(false); + expect(existsSync(join(m.root, ".codex", "skills", "demo", "SKILL.md"))).toBe(true); + + const inspect = await call(m, "inspect", { runtime: "codex" }); + expect(inspect.result.structuredContent.installed.skills).toEqual([expect.objectContaining({ alias: "demo", version: "1.0.0" })]); + }); +}); diff --git a/apps/web/tests/install/install-server.ts b/apps/web/tests/install/install-server.ts new file mode 100644 index 0000000..96a6b7e --- /dev/null +++ b/apps/web/tests/install/install-server.ts @@ -0,0 +1,81 @@ +/** + * The installer's end-to-end server: the CLI registry client's `fetch` is + * routed straight into the real route loaders (release read API, personal + * kits, collections) over the in-memory D1/R2 of the kit harness. Machines + * are temp directories that stand in for HOME/USERPROFILE, CODEX_HOME and + * SKILLX_HOME; nothing reaches the real user profile. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInstallContext } from "../../../../packages/cli/src/lib/install/install-context"; +import type { ContextOptions } from "../../../../packages/cli/src/lib/install/install-context"; +import { createRegistry } from "../../../../packages/cli/src/lib/install/registry-client"; +import { action as collectionsAction, loader as collectionsLoader } from "~/routes/api.v1.collections"; +import { loader as kitsLoader } from "~/routes/api.v1.kits"; +import { loader as releaseListLoader } from "~/routes/api.v1.package-releases"; +import { loader as fileLoader } from "~/routes/api.v1.release-file"; +import { loader as manifestLoader } from "~/routes/api.v1.release-manifest"; +import { createKitHarness } from "../personal-kits/kit-harness"; +import { FakeRateLimiter } from "../releases/fake-rate-limiter"; +import { TEST_KEY_ID } from "../releases/release-harness"; + +type Loader = (args: never) => Promise | Response; + +const ROUTES: Array<[RegExp, Loader, (m: RegExpExecArray) => Record]> = [ + [/^\/api\/v1\/releases\/([^/]+)\/manifest$/, manifestLoader as Loader, (m) => ({ digest: m[1]! })], + [/^\/api\/v1\/releases\/([^/]+)\/files\/(.+)$/, fileLoader as Loader, (m) => ({ digest: m[1]!, "*": m[2]! })], + [/^\/api\/v1\/packages\/(@[^/]+)\/([^/]+)\/releases$/, releaseListLoader as Loader, (m) => ({ scope: m[1]!, slug: m[2]! })], + [/^\/api\/v1\/kits\/(@[^/]+)\/([^/]+)(?:\/(.+))?$/, kitsLoader as Loader, (m) => ({ scope: m[1]!, name: m[2]!, ...(m[3] ? { "*": m[3] } : {}) })], + [/^\/api\/v1\/collections(?:\/([^/]+))?(?:\/(.+))?$/, collectionsLoader as Loader, (m) => ({ ...(m[1] ? { id: m[1] } : {}), ...(m[2] ? { "*": m[2] } : {}) })], +]; + +export async function createInstallServer() { + const k = await createKitHarness(); + const requests: string[] = []; + /** Test hook to tamper with responses (e.g. flip a byte of a file). */ + let rewrite: ((path: string, res: Response) => Promise) | null = null; + + const serverFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + requests.push(`${url.pathname}${url.search}`); + for (const [pattern, loader, params] of ROUTES) { + const m = pattern.exec(url.pathname); + if (!m) continue; + const handler = request.method !== "GET" && loader === collectionsLoader ? (collectionsAction as Loader) : loader; + const res = await handler({ request, params: params(m), context: { cloudflare: { env: envOf() } } } as never); + return rewrite ? rewrite(url.pathname, res) : res; + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const envOf = () => ({ + SKILLX_RELEASES_API: "1", + SKILLX_PERSONAL_KITS_API: "1", + SKILLX_COLLECTIONS_API: "1", + DB: k.h.deps.db, + R2: k.h.deps.bucket, + RELEASE_READ_LIMITER: new FakeRateLimiter(100_000), + RELEASE_WRITE_LIMITER: new FakeRateLimiter(100_000), + }); + + const keys = new Map([[TEST_KEY_ID, k.h.signingKey.publicKey]]); + const roots: string[] = []; + + /** A fresh machine: temp HOME/USERPROFILE with CODEX_HOME and SKILLX_HOME inside it. */ + function machine(name: string) { + const root = mkdtempSync(join(tmpdir(), `skillx-${name}-`)); + roots.push(root); + const env = { HOME: root, USERPROFILE: root, CODEX_HOME: join(root, ".codex"), SKILLX_HOME: join(root, ".skillx") }; + const context = (options: Partial = {}) => + createInstallContext({ registry: createRegistry({ baseUrl: "https://skillx.test", apiKey: k.ownerKey, fetch: serverFetch }), env, keys, ...options }); + return { root, env, context }; + } + + const cleanup = () => roots.forEach((r) => rmSync(r, { recursive: true, force: true })); + const setRewrite = (fn: typeof rewrite) => { + rewrite = fn; + }; + return { k, keys, requests, serverFetch, machine, cleanup, setRewrite }; +} diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 90e0fac..c8c2943 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -69,6 +69,8 @@ // before any R2 write. Workers Paid allows 10,000 per request; on a free account (1,000) set ~600. "SKILLX_RELEASE_SUBREQUEST_BUDGET": "5000", // Personal kit API (/api/v1/kits/@handle/name/...). "0" = off (404 everywhere). - "SKILLX_PERSONAL_KITS_API": "0" + "SKILLX_PERSONAL_KITS_API": "0", + // Collections API (/api/v1/collections/...). "0" = off (404 everywhere). Needs migration 0018. + "SKILLX_COLLECTIONS_API": "0" } } diff --git a/docs/api-reference.md b/docs/api-reference.md index b2ad9f5..95b896d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1357,6 +1357,44 @@ column and deletes the `0012_api_key_scopes.sql` row from `d1_migrations`. `/api/v1/kits/@{handle}/{name}[/...]` (flag `SKILLX_PERSONAL_KITS_API`): owner-private kit revisions, foundation rebase, conflict resolution, session-derived proposals and review, activation and locks. See [Personal Kits API](./personal-kits-api.md). +### Collections + +`/api/v1/collections[/:id[/...]]` (flag `SKILLX_COLLECTIONS_API`, 404 when off). +A collection is a shareable recipe of package references with immutable, +numbered revisions; per-user overlays and activation stay with personal kits. + +| Method | Path | Who | Body / result | +|--------|------|-----|---------------| +| GET | `/api/v1/collections` | Owner | `{collections}` (own only; 401 anonymous) | +| POST | `/api/v1/collections` | Owner | `{slug, name?, visibility?, members, config?}` → 201 `{collection, revision}` (revision 1) | +| GET | `/:id` | Reader | `{collection}` | +| PATCH | `/:id` | Owner | `{name?, visibility?}` → `{collection}` | +| DELETE | `/:id` | Owner | `{deleted: true, id}` (revisions cascade) | +| GET | `/:id/revisions` | Reader | `{revisions}` | +| POST | `/:id/revisions` | Owner | `{expectedRevision, members, config?}` → 201 `{revision}` (full replace) | +| GET | `/:id/revisions/:n` or `/latest` | Reader | `{revision}` | +| POST | `/:id/members` | Owner | `{expectedRevision, add?: Member[], remove?: string[]}` → 201 `{revision}` | +| POST | `/:id/clone` | Reader | `{slug?, name?, visibility?, revisionNumber?}` → 201 new private collection of the caller | + +- **Auth**: owner writes need a session or an unscoped account API key; scoped + keys are refused. Writes use `RELEASE_WRITE_LIMITER`, reads + `RELEASE_READ_LIMITER`. +- **Visibility**: `private` (default; non-owners get 404), `unlisted` and + `public` (anyone may read and clone). +- **Members**: `{packageName, version?, digest?, optional?}`. A member without + `version` follows the newest stable release at install time; a pinned one is + exact, and a `digest` must equal that release's `releaseDigest`. +- **Revisions** are immutable (DB trigger). `revisionDigest` is + `sha256:` of the canonical JSON of `{members, config}`; clients recompute and + compare it. A stale `expectedRevision` answers `409 collection.revision_conflict`. +- **Leak scan**: `members` and `config` pass the shared sync-leak scanner + (`findSyncLeaks` in `@skillx/contracts`); a secret-shaped value or an + absolute local path answers `422`. + +Database: migration `0019_collection_revision_lineage` adds +`collection_revisions.revision_digest`, `collections.cloned_from_revision_id` +and the `collection_revisions_immutable` trigger (rollback SQL in its header). + ### Changing release and publisher state Behind the same flag (`404 not_found` unless `SKILLX_RELEASES_API` is `"1"` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 532b8fc..9c37388 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -356,6 +356,212 @@ skillx config show --- +## Installer + +`install`, `update`, `uninstall`, `list`, `sync` and `mcp` install signed, +pinned releases into an agent runtime's skills directory. Bytes come only from +the release read API; nothing is written until every file is verified. + +### Sources + +| Ref | Meaning | +|-----|---------| +| `kit:@handle/kit` | The active revision of a personal kit (overlays applied client side) | +| `kit:@handle/kit#rev_` | That exact kit revision | +| `collection:col_[#n]` | Revision `n` of a collection (default `latest`) | +| `@publisher/package[@version] ...` | Explicit packages (newest stable release when no version) | + +### Runtimes (`--runtime`) + +| Id | Skills directory | +|----|------------------| +| `claude-code` (default) | `$CLAUDE_CONFIG_DIR/skills`, else `~/.claude/skills` | +| `codex` | `$CODEX_HOME/skills`, else `~/.codex/skills` | + +`~` is `USERPROFILE` on Windows and `HOME` elsewhere. Installer state lives in +`$SKILLX_HOME` (default `~/.skillx`). + +### Commands + +```bash +skillx install [--runtime id] [--dry-run] [--force] [--lock file | --no-lock] [--json] +skillx update [refs...] [--runtime id] [--force] [--lock file | --no-lock] [--json] +skillx uninstall [--runtime id] [--lock file | --no-lock] [--json] +skillx list [--runtime id] [--json] +skillx sync --locked [--lock file | --from kit:@h/k#rev] [--runtime id] [--dry-run] [--force] [--json] +skillx mcp [--registry url] +``` + +All accept `--registry ` (default: the configured base URL). The API key +is sent only over `https`; plain `http` is refused except for `localhost`, +`127.0.0.1`, `[::1]` and `*.localhost`. + +- **install** resolves the source to exact versions and digests, plans, then + fetches, verifies and applies. It writes `skillx.lock.json` in the current + directory unless `--no-lock`. A lock has one scope: `packages`, or one kit or + collection. Package installs merge into a packages lock (by alias); a kit or + collection install replaces its own lock. Installing into a lock of another + scope is a `usage_error` (pass `--lock other.json` or `--no-lock`). +- **update** re-resolves installed sources (all, or those named) and applies the + difference, then refreshes the lock file when its scope was updated. +- **uninstall** drops a kit, a collection, or one or more packages; each + explicit package is its own source (`packages:@scope/name`), whether it was + installed alone, with others, or by `sync --locked` from a packages lock. + Each package is dropped from a packages lock file as soon as it is + uninstalled, so a run that stops part way leaves the lock in step. + Uninstalling a kit or a collection does not rewrite its lock file: the lock + still describes that revision and can restore it with `sync --locked`. A + skill another source still needs stays. Only regular files whose SHA-256 still matches the + receipt are deleted; edited and unknown files are kept and reported. Links + are never followed: a file reached through a symlink or junction is kept, + and a skill directory that is itself a link is left alone. +- **sync --locked** restores exactly what a lock pins and refuses drift: a kit + revision whose re-resolved lock digest differs, a collection revision whose + content digest differs or whose members are not exactly the locked packages + (at a member's pinned version or digest), a release whose digest differs, or + local edits (unless `--force`). +- **--force** replaces locally edited skills (the old files are copied to + `~/.skillx/backups///`) and installs into unowned + directories, keeping unknown files. It never overrides a version conflict + between two sources sharing a skill, and never installs over a skill path that + is a link or a file (`unowned_directory`). A symlink or junction inside a + skill directory is kept while nothing is rewritten (reinstalling the same + version, `sync --locked`, another source adding itself as owner), but an + install or update that would rewrite the directory is a `contains_link` + conflict, also with `--force`: links are never copied or followed, so + remove or replace them first. +- Aliases match `^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9_-])?$` (no trailing `.`) and + are never a Windows device name (`con`, `prn`, `aux`, `nul`, `com1`-`com9`, + `lpt1`-`lpt9`, with or without an extension). + +### Verification and atomic apply + +1. The manifest signature is checked against [trusted keys](#trusted-signing-keys); + then package identity, version and `releaseDigest`. +2. Every file's size and SHA-256 must match the manifest (blob cache: + `~/.skillx/cache/blobs`). +3. Kit overlays must match their `overlayDigests`; the composed tree must hash + to `compositionDigest`. +4. `skillx.json` is fetched and checked first: a `compatible` block that blocks + or omits the runtime, or a `skillx.json` that is not a JSON object, refuses + the install (`incompatible`) before other files are downloaded. Every + manifest and overlay path must be relative, with no `..`, `.`, empty, `\` + or `:` segment; anything else is an `integrity_error`. +5. Apply per skill: stage in `/.skillx-staging/`, then re-check + the target against the receipt. A file created or edited since planning is + a `conflict` (`local_modified`) and nothing is swapped. Write a journal + (`~/.skillx/journal//.json`), move the old directory aside, + rename stage into place, write the receipt, then retire the old directory: + any file in it that neither the receipt, the new tree nor the backup + accounts for (it appeared after the re-check) is copied to + `~/.skillx/backups///` and reported as `displaced` + before the directory is deleted; a link found there is removed itself (its + target path is saved as `.link`), never followed. A rename that + Windows refuses (`EPERM`/`EBUSY`) is retried with backoff, then rolled back + to the old tree and reported as a `conflict`. If something else has taken + the skill path in the meantime, the old tree is not deleted: it is moved to + `~/.skillx/backups///-previous-tree` and listed in + the error's `parked` details. The move is a rename; across volumes (or + when the rename stays refused) regular files are copied instead and links + are recorded as `.link`, never followed. An old tree left in staging without a journal + is parked the same way. Each run first recovers interrupted journals (roll + back while the stage exists, roll forward once it is in place) and drops + `requiredBy` owners that `~/.skillx/owners/.json` no longer + records, removing (with ownership proof) a skill left with none, so a + killed run converges on rerun. A missing owners file counts as "no owners": + every receipted skill is then removed on the next run, keeping edited and + unknown files. A corrupt owners file stops the run instead. One run at a + time per `SKILLX_HOME`: `install.lock` holds the pid, is created + atomically, and is taken over only when that process is gone. + +### Trusted signing keys + +Verification fails closed: with no trusted key every install is an +`integrity_error`. Keys (raw 32-byte Ed25519, base64) are merged from: + +1. keys embedded in the CLI (none yet); +2. `~/.skillx/trusted-keys.json`: `{"keys": {"": ""}}`; +3. `SKILLX_TRUSTED_KEYS==[,...]`. + +`GET /api/v1/keys` lists the registry's public keys; confirm a key through a +second channel before trusting it. + +### Lockfile `skillx.lock.json` (`skillx.lock/v1`) + +Schema and validator: `packages/contracts/src/install-lock.ts`. It names what +to install, never where: no timestamps, machine paths or secrets. + +```json +{ + "schema": "skillx.lock/v1", + "resolver": "skillx.install-resolver/1", + "source": { "kind": "kit", "kit": "@alpha/my-kit", "revisionId": "rev_…", "revisionNumber": 3, "revisionDigest": "sha256:…" }, + "runtimes": { "claude-code": ">=1.0.0" }, + "packages": [ + { "alias": "demo", "package": "@alpha/demo", "version": "1.0.0", + "releaseDigest": "sha256:…", "compositionDigest": "sha256:…", "overlayDigests": ["sha256:…"] } + ] +} +``` + +`source.kind` is `kit`, `collection` (`collectionId`, `revisionNumber`, +`revisionDigest`) or `packages`. Packages are sorted by alias; the lock digest +is the SHA-256 of its canonical JSON. The lock is scanned for secrets and +absolute paths (`findSyncLeaks`) before it is written and when it is read. + +### Receipts and owners + +`~/.skillx/receipts//.json` (`skillx.install-receipt/v1`): + +```json +{ + "schema": "skillx.install-receipt/v1", + "runtime": "claude-code", "alias": "demo", "package": "@alpha/demo", "version": "1.0.0", + "releaseDigest": "sha256:…", "compositionDigest": "sha256:…", "overlayDigests": [], + "files": [{ "path": "SKILL.md", "sha256": "…", "mode": "100644" }], + "requiredBy": ["kit:@alpha/my-kit", "packages:@alpha/demo"], + "installedAt": "2026-10-19T12:00:00.000Z" +} +``` + +`files` is the ownership proof. `requiredBy` is the ref-count: a skill is +removed only when its last owner uninstalls. `~/.skillx/owners/.json` +(`skillx.install-owners/v1`) maps each owner key to its ref and current lock. +Receipts stay local; they are never uploaded. + +### Local MCP server (`skillx mcp`) + +Newline-delimited JSON-RPC 2.0 on stdio (`initialize`, `ping`, `tools/list`, +`tools/call`). Tools: + +| Tool | Arguments | Effect | +|------|-----------|--------| +| `inspect` | `runtime?` | Read-only: skills directory, installed skills, sources | +| `plan` | `source` or `lock`, `runtime?` | Read-only: the plan and its `planDigest` | +| `apply` | as `plan`, plus `confirm: true` and `planDigest` (both required) | Applies the plan the caller saw; `usage_error` without either, `conflict` when the fresh plan differs from `planDigest` | + +`force` is not accepted over MCP (`usage_error`); replacing local edits needs +the CLI's `--force`. + +### Exit codes + +Installer commands print a `{ok, data}` / `{ok, error}` envelope with `--json`. + +| Code | Error | Meaning | +|------|-------|---------| +| 0 | — | Success | +| 1 | `integrity_error`, `api_error` | Verification failed (nothing written), or the API failed | +| 2 | `usage_error` | Bad ref, flag or lock | +| 3 | `not_found` | Package, release, kit or collection not found | +| 4 | `incompatible` | The release refuses the runtime | +| 5 | `auth_error` | Credentials missing or rejected | +| 6 | `network_error` | Registry unreachable | +| 8 | `conflict` | Local edits, unowned directory, a link inside a skill, shared version, drift, stale plan, or a path in use | + +Code 7 is reserved for `rate_limited` in the shared CLI envelope. + +--- + ## API Endpoints | Command | Endpoint | Method | Auth Required | @@ -371,6 +577,9 @@ skillx config show | `publish ` (`release publish`) | `/api/v1/packages/@{publisher}/{slug}/releases` | POST | Yes (scoped key or `--oidc`) | | `publish --oidc`, `promote --oidc` | `/api/v1/oidc/github/token` | POST | GitHub Actions OIDC token | | `promote` | `/api/v1/packages/@{publisher}/{slug}/releases/{version}/promote` | POST | Yes (scoped key, OIDC, or owner key) | +| installer (fetch) | `/api/v1/packages/@{publisher}/{slug}/releases`, `/api/v1/releases/{digest}/manifest`, `/api/v1/releases/{digest}/files/*` | GET | No | +| installer (kit) | `/api/v1/kits/@{handle}/{kit}/lock[?revision=]` | GET | Yes (owner) | +| installer (collection) | `/api/v1/collections/{id}/revisions/{n\|latest}` | GET | Private: owner | **Auth method:** `Authorization: Bearer ` header. @@ -388,7 +597,8 @@ skillx config show | Missing API key | "API key required. Run `skillx config set-key`" | | Invalid URL | "Invalid URL format. Example: https://api.skillx.sh" | -All errors exit with code 1. +Legacy commands exit with code 1 on error; installer commands use the typed +[exit codes](#exit-codes) above. --- diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 2288d9c..7cab0c2 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -467,6 +467,26 @@ document in R2 under `statuses/sha256/`. Rollback: the header SQL of `0014_release_status_and_exec.sql` (drops both columns, clears the `d1_migrations` row); deploy the previous code first. +**Migration 0019 and the collections API.** +`0019_collection_revision_lineage` adds two nullable columns +(`collection_revisions.revision_digest`, `collections.cloned_from_revision_id`) +and the `collection_revisions_immutable` trigger, which aborts every `UPDATE` +of a stored revision. It is additive; apply it (`pnpm run db:migrate:remote`, +after a backup) before deploying code with `SKILLX_COLLECTIONS_API` set to +`"1"`. The flag is `"0"` in `apps/web/wrangler.jsonc`, so `/api/v1/collections` +answers 404 until it is enabled. Revisions written before 0019 have no digest; +the CLI then trusts the member list it recomputes. Rollback: set the flag back +to `"0"`, redeploy, then run the header SQL of +`0019_collection_revision_lineage.sql` (drops the trigger and both columns, +clears the `d1_migrations` row). + +The CLI installer (`skillx install`, `sync --locked`, `mcp`) needs no server +change beyond the release read API, but it verifies manifest signatures +against keys the user trusts (see [CLI reference](./cli-reference.md#trusted-signing-keys)). +Until the production signing public key is published, users must add it to +`~/.skillx/trusted-keys.json` or `SKILLX_TRUSTED_KEYS`; the CLI fails closed +otherwise. + ### Phase 4: Domain & SSL Setup ```bash diff --git a/packages/cli/package.json b/packages/cli/package.json index 03e9f49..03b31d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ "yaml": "^2.9.1" }, "devDependencies": { + "@skillx/contracts": "workspace:*", "@skillx/skill-tree": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.6.0", diff --git a/packages/cli/src/commands/install-runner.ts b/packages/cli/src/commands/install-runner.ts new file mode 100644 index 0000000..7460108 --- /dev/null +++ b/packages/cli/src/commands/install-runner.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk'; +import { createInstallContext } from '../lib/install/install-context.js'; +import type { InstallContext } from '../lib/install/install-context.js'; +import { InstallError, usageError } from '../lib/install/install-errors.js'; +import type { SourcePlan } from '../lib/install/install-operations.js'; +import { createRegistry } from '../lib/install/registry-client.js'; +import { exitCodeFor, failure, fromError, serialize, success } from '../lib/output.js'; +import type { CliFailure } from '../lib/output.js'; +import { getApiKey, getBaseUrl } from '../utils/config-store.js'; + +/** Options every installer command accepts. */ +export interface InstallerOptions { + runtime?: string; + registry?: string; + json?: boolean; + force?: boolean; +} + +export function contextFrom(options: InstallerOptions): InstallContext { + const baseUrl = options.registry ?? getBaseUrl(); + try { + new URL(baseUrl); + } catch { + throw usageError(`invalid registry URL ${baseUrl}`); + } + return createInstallContext({ + registry: createRegistry({ baseUrl, apiKey: getApiKey() }), + ...(options.runtime ? { runtime: options.runtime } : {}), + force: options.force === true, + }); +} + +function toFailure(error: unknown): CliFailure { + if (error instanceof InstallError) return failure(error.code, error.message, error.details === undefined ? {} : { details: error.details }); + return fromError(error); +} + +/** Runs one command body: JSON envelope or human text, and the exit code of the outcome. */ +export async function runInstaller(options: InstallerOptions, body: () => Promise, human: (data: T) => void): Promise { + try { + const data = await body(); + if (options.json) console.log(serialize(success(data))); + else human(data); + process.exit(0); + } catch (error) { + const envelope = toFailure(error); + if (options.json) console.log(serialize(envelope)); + else { + console.error(chalk.red(`\n✗ ${envelope.error.message}`)); + const conflicts = (envelope.error.details as { conflicts?: Array<{ alias: string; reason: string; files: string[] }> } | undefined)?.conflicts; + for (const c of conflicts ?? []) console.error(chalk.yellow(` ${c.alias}: ${c.reason}${c.files.length ? ` (${c.files.slice(0, 5).join(', ')})` : ''}`)); + } + process.exit(exitCodeFor(envelope)); + } +} + +export function printPlan(planned: SourcePlan): void { + for (const item of planned.items) { + console.log(chalk.bold(`\n${item.owner}`) + chalk.dim(` → ${item.plan.skillsDir}`)); + for (const a of item.plan.actions) { + const paint = a.action === 'conflict' ? chalk.red : a.action === 'unchanged' ? chalk.dim : chalk.green; + const detail = a.action === 'conflict' ? ` ${a.reason}` : 'version' in a ? ` ${a.package}@${a.version}` : ''; + console.log(` ${paint(a.action.padEnd(9))} ${a.alias}${chalk.dim(detail)}`); + } + } + for (const w of planned.warnings) console.log(chalk.yellow(` ! ${w}`)); +} diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..75499eb --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,158 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { INSTALL_LOCK_FILENAME } from '@skillx/contracts'; +import { installRefs, listInstalled, ownersOfRefs, planRefs, uninstallRef, updateInstalled } from '../lib/install/install-operations.js'; +import type { ApplyOutcome } from '../lib/install/install-operations.js'; +import { usageError } from '../lib/install/install-errors.js'; +import { planLocked, syncLocked } from '../lib/install/sync-locked.js'; +import { assertLockScope, dropFromLockFile, existingLock, lockScope, mergeLock, readLockFile, writeLockFile } from '../lib/install/lock-file.js'; +import { parseRefs } from '../lib/install/resolver.js'; +import { contextFrom, printPlan, runInstaller } from './install-runner.js'; +import type { InstallerOptions } from './install-runner.js'; + +/** + * `skillx install | update | uninstall | list | sync`: the generic installer. + * Sources are `kit:@handle/kit[#revisionId]`, `collection:[#revision]` + * or `@publisher/package[@version]`; `--runtime` picks the adapter + * (`claude-code`, default, or `codex`). See docs/cli-reference.md. + */ + +interface ApplyOptions extends InstallerOptions { + dryRun?: boolean; + lock?: string | false; +} + +const withCommon = (cmd: Command) => + cmd + .option('--runtime ', 'target runtime: claude-code or codex', 'claude-code') + .option('--registry ', 'registry base URL (default: the configured one)') + .option('--json', 'emit a machine-readable envelope instead of text'); + +function printApplied(outcome: ApplyOutcome, lockPath: string | null): void { + printPlan(outcome); + for (const a of outcome.applied) { + for (const c of a.changed) if (c.backupDir) console.log(chalk.yellow(` ${c.alias}: local changes saved to ${c.backupDir}`)); + for (const r of a.removed) if (r.keptModified.length + r.keptUnknown.length > 0) console.log(chalk.yellow(` ${r.alias}: kept ${[...r.keptModified, ...r.keptUnknown].join(', ')}`)); + } + if (lockPath) console.log(chalk.dim(`\nlock written to ${lockPath}`)); + console.log(chalk.green('\n✓ done')); +} + +export const installCommand = withCommon(new Command('install')) + .description('Install a kit revision, a collection or packages into a runtime skills directory') + .argument('', 'kit:@handle/kit[#revisionId] | collection:[#n] | @publisher/package[@version]...') + .option('--dry-run', 'resolve and plan only; change nothing') + .option('--force', 'reinstall over local edits (backed up) and unowned directories (unknown files kept)') + .option('--lock ', 'where to write the lock', INSTALL_LOCK_FILENAME) + .option('--no-lock', 'do not write a lock file') + .action((refs: string[], options: ApplyOptions) => + runInstaller( + options, + async () => { + const ctx = contextFrom(options); + if (options.dryRun) return { dryRun: true as const, ...(await planRefs(ctx, refs)) }; + const path = options.lock === false ? null : (options.lock ?? INSTALL_LOCK_FILENAME); + if (path !== null) assertLockScope(path, parseRefs(refs).kind === 'packages' ? 'packages' : ownersOfRefs(refs)[0]!); + const outcome = await installRefs(ctx, refs); + const lockPath = path === null ? null : writeLockFile(path, mergeLock(existingLock(path), outcome.lock)); + return { ...outcome, lockPath }; + }, + (data) => ('dryRun' in data ? printPlan(data) : printApplied(data, data.lockPath)), + ), + ); + +export const updateCommand = withCommon(new Command('update')) + .description('Re-resolve installed sources (all, or the one named) and apply the changes') + .argument('[refs...]', 'an installed source; all when omitted') + .option('--force', 'reinstall over local edits (backed up)') + .option('--lock ', 'lock file to refresh when it locks an updated source', INSTALL_LOCK_FILENAME) + .option('--no-lock', 'leave the lock file alone') + .action((refs: string[], options: ApplyOptions) => + runInstaller( + options, + async () => { + const outcomes = await updateInstalled(contextFrom(options), refs); + const path = options.lock === false ? null : (options.lock ?? INSTALL_LOCK_FILENAME); + let lockPath: string | null = null; + for (const o of outcomes) { + const existing = path === null ? null : existingLock(path); + if (existing !== null && lockScope(existing) === lockScope(o.lock)) lockPath = writeLockFile(path!, mergeLock(existing, o.lock)); + } + return { outcomes, lockPath }; + }, + (data) => data.outcomes.forEach((o, i) => printApplied(o, i === data.outcomes.length - 1 ? data.lockPath : null)), + ), + ); + +export const uninstallCommand = withCommon(new Command('uninstall')) + .description('Remove a source; skills another source still needs stay, edited and unknown files are kept') + .argument('', 'the source as installed: kit:@handle/kit, collection: or @publisher/package') + .option('--lock ', 'packages lock file to drop the package from', INSTALL_LOCK_FILENAME) + .option('--no-lock', 'leave the lock file alone') + .action((refs: string[], options: ApplyOptions) => + runInstaller( + options, + async () => { + let lockPath: string | null = null; + // Each package leaves the packages lock as soon as it is uninstalled, so a later failure leaves the lock in step. + const dropFromLock = (owner: string) => { + if (options.lock === false || !owner.startsWith('packages:')) return; + lockPath = dropFromLockFile(options.lock ?? INSTALL_LOCK_FILENAME, owner.slice('packages:'.length)) ?? lockPath; + }; + const results = await uninstallRef(contextFrom(options), refs, dropFromLock); + return { uninstalled: results, lockPath }; + }, + (data) => { + for (const result of data.uninstalled) { + for (const c of result.changed) console.log(` ${chalk.dim('released')} ${c.alias}`); + for (const r of result.removed) { + console.log(` ${chalk.green('removed')} ${r.alias}`); + const kept = [...r.keptModified, ...r.keptUnknown]; + if (kept.length > 0) console.log(chalk.yellow(` kept ${kept.join(', ')}`)); + } + console.log(chalk.green(`✓ ${result.owner} uninstalled`)); + } + if (data.lockPath) console.log(chalk.dim(`\nlock updated: ${data.lockPath}`)); + }, + ), + ); + +export const listCommand = withCommon(new Command('list')) + .description('List installed skills and the sources that need them') + .action((options: InstallerOptions) => + runInstaller( + options, + async () => listInstalled(contextFrom(options)), + (data) => { + console.log(chalk.bold(`\n${data.runtime}`) + chalk.dim(` ${data.skillsDir}`)); + for (const s of data.skills) console.log(` ${s.alias.padEnd(28)} ${s.package}@${s.version} ${chalk.dim(s.requiredBy.join(', '))}`); + if (data.skills.length === 0) console.log(chalk.dim(' nothing installed')); + }, + ), + ); + +export const syncCommand = withCommon(new Command('sync')) + .description('Restore exactly what a lock pins (from a lock file, or a kit revision with --from)') + .option('--locked', 'required: restore exact digests and refuse any drift') + .option('--lock ', 'lock file to restore', INSTALL_LOCK_FILENAME) + .option('--from ', 'restore a kit revision instead of a lock file: kit:@handle/kit#revisionId') + .option('--dry-run', 'plan only; change nothing') + .option('--force', 'repair local edits (backed up) instead of refusing') + .action((options: ApplyOptions & { locked?: boolean; from?: string }) => + runInstaller( + options, + async () => { + if (!options.locked) throw usageError('sync restores exact digests only; pass --locked'); + const ctx = contextFrom(options); + if (options.from !== undefined) { + if (!/^kit:.+#.+$/.test(options.from)) throw usageError('--from needs an exact kit revision: kit:@handle/kit#revisionId'); + if (options.dryRun) return { dryRun: true as const, ...(await planRefs(ctx, [options.from])) }; + return { ...(await installRefs(ctx, [options.from])), lockPath: null }; + } + const lock = readLockFile(options.lock || INSTALL_LOCK_FILENAME); + if (options.dryRun) return { dryRun: true as const, ...(await planLocked(ctx, lock)) }; + return { ...(await syncLocked(ctx, lock)), lockPath: null }; + }, + (data) => ('dryRun' in data ? printPlan(data) : printApplied(data, null)), + ), + ); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000..1e1eec4 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,18 @@ +import { Command } from 'commander'; +import { serveStdio } from '../lib/install/local-mcp.js'; +import { contextFrom } from './install-runner.js'; + +/** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ +declare const __SKILLX_VERSION__: string; + +/** + * `skillx mcp`: the local installer as an MCP server on stdio, with the + * tools `inspect`, `plan` and `apply` (apply requires `confirm: true`). + * This is not the remote registry MCP at `/api/mcp`. + */ +export const mcpCommand = new Command('mcp') + .description('Serve the local installer (inspect, plan, apply) as an MCP server on stdio') + .option('--registry ', 'registry base URL (default: the configured one)') + .action(async (options: { registry?: string }) => { + await serveStdio((args) => contextFrom({ ...options, ...args }), __SKILLX_VERSION__); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f01cdc9..5681c2e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,8 @@ import { validateCommand } from './commands/validate.js'; import { createPackCommand } from './commands/pack.js'; import { promoteCommand } from './commands/promote.js'; import { releaseCommand } from './commands/release-publish.js'; +import { installCommand, listCommand, syncCommand, uninstallCommand, updateCommand } from './commands/install.js'; +import { mcpCommand } from './commands/mcp.js'; /** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ declare const __SKILLX_VERSION__: string; @@ -37,5 +39,6 @@ program.addCommand(createPackCommand()); program.addCommand(publishCommand); program.addCommand(promoteCommand); program.addCommand(releaseCommand); +for (const command of [installCommand, updateCommand, uninstallCommand, listCommand, syncCommand, mcpCommand]) program.addCommand(command); program.parse(process.argv); diff --git a/packages/cli/src/lib/install/adapters.ts b/packages/cli/src/lib/install/adapters.ts new file mode 100644 index 0000000..8fa345c --- /dev/null +++ b/packages/cli/src/lib/install/adapters.ts @@ -0,0 +1,63 @@ +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { usageError } from './install-errors.js'; + +/** + * Harness adapters: where a runtime loads skills from. An adapter only maps a + * runtime id to a skills directory; the installer does everything else the + * same way for every runtime. Every location is derived from the process + * environment at call time (HOME/USERPROFILE through `os.homedir()`, + * `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `SKILLX_HOME`), so tests point all of + * them at a temporary directory and never touch real user state. + */ + +export type Env = Record; + +export interface HarnessAdapter { + /** Runtime id, the key used by compatibility declarations and kit `runtimes`. */ + id: 'claude-code' | 'codex'; + displayName: string; + /** Directory holding one sub-directory per installed skill. */ + skillsDir(env?: Env): string; +} + +/** HOME on POSIX and USERPROFILE on Windows, as `os.homedir()` reads them, but from `env`. */ +export function homeDir(env: Env = process.env): string { + const fromEnv = process.platform === 'win32' ? env.USERPROFILE : env.HOME; + return fromEnv ? resolve(fromEnv) : homedir(); +} + +/** SkillX local state: receipts, journals, owner records, the blob cache. */ +export function skillxHome(env: Env = process.env): string { + return env.SKILLX_HOME ? resolve(env.SKILLX_HOME) : join(homeDir(env), '.skillx'); +} + +export const claudeCodeAdapter: HarnessAdapter = { + id: 'claude-code', + displayName: 'Claude Code', + skillsDir: (env = process.env) => join(env.CLAUDE_CONFIG_DIR ? resolve(env.CLAUDE_CONFIG_DIR) : join(homeDir(env), '.claude'), 'skills'), +}; + +export const codexAdapter: HarnessAdapter = { + id: 'codex', + displayName: 'Codex', + skillsDir: (env = process.env) => join(env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(homeDir(env), '.codex'), 'skills'), +}; + +export const ADAPTERS: readonly HarnessAdapter[] = [claudeCodeAdapter, codexAdapter]; +export const DEFAULT_RUNTIME = claudeCodeAdapter.id; + +export function getAdapter(id: string): HarnessAdapter { + const adapter = ADAPTERS.find((a) => a.id === id); + if (!adapter) throw usageError(`unknown runtime '${id}'; use one of: ${ADAPTERS.map((a) => a.id).join(', ')}`); + return adapter; +} + +/** + * Staging and backup area of an adapter: a sibling of the skills directory, + * so a rename never crosses a filesystem and the runtime never loads a + * half-written skill from it. + */ +export function stagingDir(adapter: HarnessAdapter, env: Env = process.env): string { + return join(dirname(adapter.skillsDir(env)), '.skillx-staging'); +} diff --git a/packages/cli/src/lib/install/apply-transaction.ts b/packages/cli/src/lib/install/apply-transaction.ts new file mode 100644 index 0000000..7b56ef9 --- /dev/null +++ b/packages/cli/src/lib/install/apply-transaction.ts @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; +import { cpSync, existsSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { VerifiedTree } from './fetch-verify.js'; +import { conflictError } from './install-errors.js'; +import { writeJsonAtomic, writeReceipt } from './install-state.js'; +import { exists, journalDir, recoverJournals } from './journal-recovery.js'; +import type { Journal } from './journal-recovery.js'; +import type { InstallReceipt } from './install-state.js'; +import { containedPath, isRealDir, renameWithRetry } from './safe-fs.js'; +import type { RenameHooks } from './safe-fs.js'; +import { retireOld, unprovenFiles } from './tree-proof.js'; + +export { recoverJournals } from './journal-recovery.js'; +export type { Recovery } from './journal-recovery.js'; + +/** + * Atomic replacement of one skill directory, recoverable after a crash at + * any point: + * + * stage files → prove the old tree → write journal → target → old + * → stage → target → write receipt → retire old → drop journal + * + * Before the journal is written, every file of the current directory must be + * accounted for (owned and unchanged, carried into the stage, or backed up); + * a file created or edited since the plan aborts the change. The old tree is + * then never deleted blindly: anything in it still unproven at retire time + * is copied to the backup area first. + * + * `recoverJournals` (journal-recovery.ts) runs before every change and when a + * rename is refused, and finishes or rolls back the transaction. + */ + +export type CrashPoint = 'staged' | 'journaled' | 'moved-old' | 'swapped' | 'receipt-written'; + +export interface TxHooks extends RenameHooks { + /** Test hook: throwing here simulates a crash at that point. */ + at?(point: CrashPoint, alias: string): void; +} + +export interface TxContext { + home: string; + runtime: string; + skillsDir: string; + stagingDir: string; + hooks?: TxHooks; + now: Date; +} + +const stamp = (now: Date) => now.toISOString().replace(/[:.]/g, '-'); +const savedRoot = (ctx: TxContext, alias: string) => join(ctx.home, 'backups', ctx.runtime, alias, stamp(ctx.now)); + +/** Copies regular files only: a link or other entry that appeared since the plan is a conflict, never followed. */ +function copyInto(alias: string, fromRoot: string, toRoot: string, paths: readonly string[]): void { + for (const path of paths) { + const from = containedPath(fromRoot, path); + if (!lstatSync(from).isFile()) throw conflictError(`${alias}/${path} is a link or not a regular file; nothing was replaced`, { conflicts: [{ alias, reason: 'contains_link', files: [path] }] }); + const dest = containedPath(toRoot, path); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(from, dest); + } +} + +export interface TreeChange { + alias: string; + tree: VerifiedTree; + receipt: InstallReceipt; + /** The receipt of the directory being replaced; null for a first install. */ + previous: InstallReceipt | null; + /** Local files that are not part of the release; carried into the new directory. */ + preserve: readonly string[]; + /** Owned files changed locally; saved to the backup area before the swap. */ + backup: readonly string[]; +} + + +export function applyTreeChange(ctx: TxContext, change: TreeChange): { backupDir: string | null; preserved: string[]; displaced: string[] } { + const hit = (point: CrashPoint) => ctx.hooks?.at?.(point, change.alias); + const target = join(ctx.skillsDir, change.alias); + if (exists(target) && !isRealDir(target)) throw conflictError(`${target} is a link or a file, not a skill directory; nothing was changed`, { alias: change.alias }); + const id = `${change.alias}.${randomUUID()}`; + const stage = join(ctx.stagingDir, id); + const backup = join(ctx.stagingDir, `${id}.old`); + const saved = savedRoot(ctx, change.alias); + mkdirSync(ctx.skillsDir, { recursive: true }); + + for (const file of change.tree.files) { + const dest = containedPath(stage, file.path); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, file.bytes, { mode: file.mode === '100755' ? 0o755 : 0o644 }); + } + const releasePaths = new Set(change.tree.files.map((f) => f.path)); + const preserved = change.preserve.filter((p) => !releasePaths.has(p)); + const toBackUp = [...change.backup, ...change.preserve.filter((p) => releasePaths.has(p))]; + try { + if (toBackUp.length > 0) copyInto(change.alias, target, saved, toBackUp); + copyInto(change.alias, target, stage, preserved); + } catch (error) { + rmSync(stage, { recursive: true, force: true }); + throw error; + } + hit('staged'); + + const drift = unprovenFiles({ dir: target, previous: change.previous, carried: stage, saved }); + if (drift.length > 0) { + rmSync(stage, { recursive: true, force: true }); + throw conflictError(`${change.alias} changed on disk since it was planned; nothing was replaced`, { conflicts: [{ alias: change.alias, reason: 'local_modified', files: drift }] }); + } + + const journal: Journal = { schema: 'skillx.install-journal/v2', alias: change.alias, target, stage, backup, saved, previous: change.previous, receipt: change.receipt }; + const journalPath = join(journalDir(ctx), `${change.alias}.json`); + writeJsonAtomic(journalPath, journal); + hit('journaled'); + const busy = (error: unknown): never => { + let parked: Array<{ alias: string; savedTo: string }> = []; + try { + parked = recoverJournals(ctx).parked; + } catch { + // The journal stays; the next run recovers. + } + const code = (error as NodeJS.ErrnoException).code ?? 'unknown'; + throw conflictError(`${target} is in use (${code}); close programs using it and run again`, { alias: change.alias, code, parked }); + }; + try { + if (exists(target)) renameWithRetry(target, backup, ctx.hooks); + } catch (error) { + busy(error); + } + hit('moved-old'); + try { + renameWithRetry(stage, target, ctx.hooks); + } catch (error) { + busy(error); + } + hit('swapped'); + writeReceipt(ctx.home, change.receipt); + hit('receipt-written'); + const displaced = retireOld({ dir: backup, previous: change.previous, carried: target, saved }); + rmSync(journalPath, { force: true }); + return { backupDir: existsSync(saved) ? saved : null, preserved, displaced }; +} diff --git a/packages/cli/src/lib/install/fetch-verify.ts b/packages/cli/src/lib/install/fetch-verify.ts new file mode 100644 index 0000000..971ae56 --- /dev/null +++ b/packages/cli/src/lib/install/fetch-verify.ts @@ -0,0 +1,139 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { applyOverlay, canonicalJsonBytes, kitTreeDigest, normalizeCompatibility, validateOverlay, verifyReleaseManifest } from '@skillx/contracts'; +import type { InstallLockPackage, KitTree, SkillOverlay, SkillxPackageManifest, SkillxReleaseManifest } from '@skillx/contracts'; +import { incompatibleError, integrityError } from './install-errors.js'; +import type { Registry } from './registry-client.js'; +import { containedPath } from './safe-fs.js'; + +/** + * Fetch and verify one locked package. Nothing is trusted until checked: + * the manifest must be canonical, signed by a trusted key and name exactly + * the pinned identity, version and release digest; every file must match its + * manifest size and SHA-256; every overlay must hash to its locked digest; + * and the composed tree must hash to `compositionDigest`. Verified file + * bytes are cached by SHA-256 under `/cache/blobs`. + */ + +export interface VerifiedFile { + path: string; + mode: '100644' | '100755'; + sha256: string; + size: number; + bytes: Uint8Array; +} + +export interface VerifiedTree { + digest: string; + files: VerifiedFile[]; +} + +export interface FetchDeps { + registry: Registry; + keys: ReadonlyMap; + cacheDir: string; + runtime: string; +} + +export const sha256Hex = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex'); + +function cached(cacheDir: string, sha256: string): Uint8Array | null { + try { + const bytes = new Uint8Array(readFileSync(join(cacheDir, sha256))); + return sha256Hex(bytes) === sha256 ? bytes : null; + } catch { + return null; + } +} + +function store(cacheDir: string, sha256: string, bytes: Uint8Array): void { + mkdirSync(cacheDir, { recursive: true }); + const tmp = join(cacheDir, `.${sha256}.${randomUUID()}.tmp`); + writeFileSync(tmp, bytes); + renameSync(tmp, join(cacheDir, sha256)); +} + +async function verifiedManifest(deps: FetchDeps, pkg: InstallLockPackage): Promise { + const bytes = await deps.registry.manifest(pkg.releaseDigest, pkg.package, pkg.version); + const verdict = await verifyReleaseManifest(bytes, deps.keys); + if (!verdict.valid) { + throw integrityError(`the manifest of ${pkg.package}@${pkg.version} failed verification`, { package: pkg.package, issues: verdict.issues.map((i) => i.code) }); + } + const manifest = JSON.parse(new TextDecoder().decode(bytes)) as SkillxReleaseManifest; + if (manifest.identity !== pkg.package || manifest.version !== pkg.version || manifest.releaseDigest !== pkg.releaseDigest) { + throw integrityError(`the signed manifest does not name ${pkg.package}@${pkg.version} ${pkg.releaseDigest}`, { package: pkg.package }); + } + return manifest; +} + +async function fileBytes(deps: FetchDeps, pkg: InstallLockPackage, file: SkillxReleaseManifest['files'][number]): Promise { + const hit = cached(deps.cacheDir, file.sha256); + if (hit !== null && hit.length === file.size) return hit; + const bytes = await deps.registry.file(pkg.releaseDigest, pkg.package, pkg.version, file.path); + if (bytes.length !== file.size || sha256Hex(bytes) !== file.sha256) { + throw integrityError(`${file.path} of ${pkg.package}@${pkg.version} does not match its signed manifest`, { package: pkg.package, path: file.path }); + } + store(deps.cacheDir, file.sha256, bytes); + return bytes; +} + +/** Refuses a `skillx.json` that is unreadable or declares the target runtime unsupported or blocked. */ +function checkCompatibility(bytes: Uint8Array | undefined, pkg: InstallLockPackage, runtime: string): void { + if (bytes === undefined) return; + let declared: SkillxPackageManifest; + try { + declared = JSON.parse(new TextDecoder().decode(bytes)) as SkillxPackageManifest; + } catch { + throw incompatibleError(`the skillx.json of ${pkg.package}@${pkg.version} is not valid JSON; its compatibility cannot be checked`, { package: pkg.package }); + } + if (declared === null || typeof declared !== 'object') throw incompatibleError(`the skillx.json of ${pkg.package}@${pkg.version} is not an object`, { package: pkg.package }); + if (declared.compatible === undefined) return; + const verdict = normalizeCompatibility({ runtime }, { declaration: declared.compatible, releaseDigest: pkg.releaseDigest }); + if (verdict.status === 'unsupported' || verdict.status === 'blocked') { + throw incompatibleError(`${pkg.package}@${pkg.version} is ${verdict.status} on ${runtime}`, { package: pkg.package, runtime, status: verdict.status }); + } +} + +export async function fetchVerified(deps: FetchDeps, pkg: InstallLockPackage, overlays: readonly SkillOverlay[]): Promise { + const manifest = await verifiedManifest(deps, pkg); + for (const file of manifest.files) containedPath('/', file.path); + const bytesByPath = new Map(); + // The compatibility declaration is checked before any other file is downloaded. + const declaration = manifest.files.find((f) => f.path === 'skillx.json'); + if (declaration !== undefined) { + bytesByPath.set(declaration.path, await fileBytes(deps, pkg, declaration)); + checkCompatibility(bytesByPath.get(declaration.path), pkg, deps.runtime); + } + for (const file of manifest.files) if (!bytesByPath.has(file.path)) bytesByPath.set(file.path, await fileBytes(deps, pkg, file)); + + let tree: KitTree = { + digest: manifest.releaseDigest, + files: manifest.files.map(({ path, mode, size, sha256 }) => ({ path, mode, size, sha256 })), + read: async (path) => bytesByPath.get(path) ?? Promise.reject(new Error(`no file ${path}`)), + declaredScripts: manifest.declaredScripts, + }; + if (overlays.length !== pkg.overlayDigests.length) throw integrityError(`the overlays of ${pkg.alias} do not match the lock`, { alias: pkg.alias }); + for (const [i, overlay] of overlays.entries()) { + if (`sha256:${sha256Hex(canonicalJsonBytes(overlay))}` !== pkg.overlayDigests[i]) { + throw integrityError(`overlay ${i + 1} of ${pkg.alias} does not match its locked digest`, { alias: pkg.alias }); + } + try { + tree = await applyOverlay(tree, validateOverlay(overlay)); + } catch (error) { + throw integrityError(`overlay ${i + 1} of ${pkg.alias} does not apply: ${error instanceof Error ? error.message : String(error)}`, { alias: pkg.alias }); + } + } + const digest = await kitTreeDigest(tree.files); + if (digest !== pkg.compositionDigest) { + throw integrityError(`the composed tree of ${pkg.alias} does not match compositionDigest`, { alias: pkg.alias, expected: pkg.compositionDigest, actual: digest }); + } + const files: VerifiedFile[] = []; + for (const f of tree.files) { + containedPath('/', f.path); + files.push({ path: f.path, mode: f.mode as VerifiedFile['mode'], sha256: f.sha256, size: f.size, bytes: await tree.read(f.path) }); + } + // An overlay may have changed the declaration: check the composed tree again. + if (overlays.length > 0) checkCompatibility(files.find((f) => f.path === 'skillx.json')?.bytes, pkg, deps.runtime); + return { digest, files }; +} diff --git a/packages/cli/src/lib/install/install-context.ts b/packages/cli/src/lib/install/install-context.ts new file mode 100644 index 0000000..3e21c67 --- /dev/null +++ b/packages/cli/src/lib/install/install-context.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto'; +import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DEFAULT_RUNTIME, getAdapter, skillxHome, stagingDir } from './adapters.js'; +import type { Env, HarnessAdapter } from './adapters.js'; +import type { TxHooks } from './apply-transaction.js'; +import { InstallError } from './install-errors.js'; +import type { Registry } from './registry-client.js'; +import { loadTrustedKeys } from './trusted-keys.js'; + +/** Everything one installer run needs, resolved once from the environment. */ +export interface InstallContext { + env: Env; + home: string; + adapter: HarnessAdapter; + runtime: string; + skillsDir: string; + stagingDir: string; + registry: Registry; + keys: ReadonlyMap; + force: boolean; + hooks?: TxHooks; + now: () => Date; +} + +export interface ContextOptions { + registry: Registry; + env?: Env; + runtime?: string; + keys?: ReadonlyMap; + force?: boolean; + hooks?: InstallContext['hooks']; + now?: () => Date; +} + +export function createInstallContext(options: ContextOptions): InstallContext { + const env = options.env ?? process.env; + const home = skillxHome(env); + const adapter = getAdapter(options.runtime ?? DEFAULT_RUNTIME); + return { + env, + home, + adapter, + runtime: adapter.id, + skillsDir: adapter.skillsDir(env), + stagingDir: stagingDir(adapter, env), + registry: options.registry, + keys: options.keys ?? loadTrustedKeys(home, env), + force: options.force ?? false, + ...(options.hooks ? { hooks: options.hooks } : {}), + now: options.now ?? (() => new Date()), + }; +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +const busy = (holder: string) => new InstallError('conflict', `another skillx process (${holder}) is changing installed skills`, { holder }); + +/** + * One writer at a time per SkillX home. The lock is created complete or not + * at all: the pid is written to a private file that is then hard-linked to + * `install.lock`, which fails atomically when the lock exists. A lock whose + * holder no longer runs is taken over by renaming it to a private name + * first, so of two processes taking over the same stale lock only one wins, + * and a fresh lock that replaced it in between is put back, never deleted. + */ +export async function withInstallLock(ctx: InstallContext, run: () => Promise): Promise { + mkdirSync(ctx.home, { recursive: true }); + const path = join(ctx.home, 'install.lock'); + const mine = `${path}.${process.pid}.${randomUUID()}`; + writeFileSync(mine, String(process.pid)); + try { + for (let attempt = 0; ; attempt += 1) { + try { + linkSync(mine, path); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if (attempt >= 2) throw busy('a process that keeps taking the lock'); + } + const text = readText(path); + // Released between our link attempt and this read: try again. + if (text === null) continue; + const holder = Number.parseInt(text, 10); + if (!Number.isSafeInteger(holder)) throw busy(`unknown; remove ${path} if no skillx command is running`); + if (alive(holder)) throw busy(`pid ${holder}`); + const stale = `${path}.stale.${randomUUID()}`; + try { + renameSync(path, stale); + } catch { + continue; // someone else took it over first; try again + } + const taken = Number.parseInt(readText(stale) ?? '', 10); + if (taken !== holder) { + // A live process replaced the stale lock between our read and rename: give it back. + try { + linkSync(stale, path); + } catch { + // It has its own lock again; ours stays out of the way. + } + rmSync(stale, { force: true }); + throw busy(`pid ${taken}`); + } + rmSync(stale, { force: true }); + } + } finally { + rmSync(mine, { force: true }); + } + try { + return await run(); + } finally { + rmSync(path, { force: true }); + } +} + +/** The file's text; null when it does not exist (any other read failure reads as empty). */ +function readText(path: string): string | null { + try { + return readFileSync(path, 'utf8'); + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : ''; + } +} diff --git a/packages/cli/src/lib/install/install-engine.ts b/packages/cli/src/lib/install/install-engine.ts new file mode 100644 index 0000000..e41ce76 --- /dev/null +++ b/packages/cli/src/lib/install/install-engine.ts @@ -0,0 +1,133 @@ +import { lockOwnerKey } from '@skillx/contracts'; +import type { InstallLock, SkillOverlay } from '@skillx/contracts'; +import { applyTreeChange, recoverJournals } from './apply-transaction.js'; +import type { Recovery } from './apply-transaction.js'; +import { fetchVerified } from './fetch-verify.js'; +import type { VerifiedTree } from './fetch-verify.js'; +import type { InstallContext } from './install-context.js'; +import { conflictError } from './install-errors.js'; +import { RECEIPT_SCHEMA, listReceipts, readOwners, readReceipt, writeOwners, writeReceipt } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; +import { packagesToFetch, planInstall } from './planner.js'; +import type { InstallPlan } from './planner.js'; +import { removeOwned } from './remove-owned.js'; +import type { RemoveResult } from './remove-owned.js'; +import { join } from 'node:path'; + +/** + * Plan and apply for one owner (a kit, a collection or one package). A + * multi-package install is split into one owner per package, so each can be + * updated and uninstalled on its own. Apply verifies every tree before the + * first write, refuses any plan with a conflict, and records the owner's + * lock last, so a rerun after a crash converges. + */ + +export interface OwnedLock { + owner: string; + /** What `update` resolves again: the ref as the user gave it. */ + ref: string; + lock: InstallLock; + overlays: Map; +} + +/** Splits a lock into owners: kits and collections own all their packages; explicit packages own themselves. */ +export function ownedLocks(lock: InstallLock, overlays: Map, refFor: (pkg: string) => string, sourceRef: string): OwnedLock[] { + if (lock.source.kind !== 'packages') return [{ owner: lockOwnerKey(lock.source, lock.packages), ref: sourceRef, lock, overlays }]; + return lock.packages.map((p) => { + const single = { ...lock, packages: [p] }; + return { owner: lockOwnerKey(lock.source, single.packages), ref: refFor(p.package), lock: single, overlays: new Map() }; + }); +} + +export function planOwned(ctx: InstallContext, owned: Pick): InstallPlan { + return planInstall({ + home: ctx.home, + runtime: ctx.runtime, + skillsDir: ctx.skillsDir, + owner: owned.owner, + packages: owned.lock.packages, + owners: readOwners(ctx.home, ctx.runtime), + force: ctx.force, + }); +} + +export interface ApplyResult { + owner: string; + planDigest: string; + changed: Array<{ alias: string; action: string; backupDir: string | null; preserved: string[]; displaced: string[] }>; + removed: RemoveResult[]; +} + +/** + * Finishes interrupted transactions, then drops owner keys that receipts + * name but the owners record does not (a run that stopped after writing + * receipts and before recording its owner). A skill left with no owner is + * removed with ownership proof, so the next plan starts from a clean slate. + */ +export function recover(ctx: InstallContext): Recovery & { orphaned: string[] } { + const recovery = recoverJournals({ home: ctx.home, runtime: ctx.runtime, stagingDir: ctx.stagingDir, ...(ctx.hooks ? { hooks: ctx.hooks } : {}) }); + const known = new Set(Object.keys(readOwners(ctx.home, ctx.runtime).owners)); + const orphaned: string[] = []; + for (const receipt of listReceipts(ctx.home, ctx.runtime)) { + const requiredBy = receipt.requiredBy.filter((o) => known.has(o)); + if (requiredBy.length === receipt.requiredBy.length) continue; + orphaned.push(receipt.alias); + if (requiredBy.length > 0) writeReceipt(ctx.home, { ...receipt, requiredBy }); + else removeOwned(ctx.home, ctx.runtime, ctx.skillsDir, receipt); + } + return { ...recovery, orphaned }; +} + +function refuseConflicts(plan: InstallPlan): void { + if (plan.conflicts === 0) return; + const conflicts = plan.actions.filter((a) => a.action === 'conflict'); + throw conflictError(`${plan.conflicts} skill(s) conflict with local files or other sources; nothing was changed`, { owner: plan.owner, conflicts }); +} + +/** Applies a fresh plan. With `uninstall`, the owner's record is dropped instead of written. */ +export async function applyOwned(ctx: InstallContext, owned: OwnedLock, plan: InstallPlan, uninstall = false): Promise { + refuseConflicts(plan); + const trees = new Map(); + const deps = { registry: ctx.registry, keys: ctx.keys, cacheDir: join(ctx.home, 'cache', 'blobs'), runtime: ctx.runtime }; + for (const pkg of packagesToFetch(plan, owned.lock)) trees.set(pkg.alias, await fetchVerified(deps, pkg, owned.overlays.get(pkg.alias) ?? [])); + + const result: ApplyResult = { owner: owned.owner, planDigest: plan.planDigest, changed: [], removed: [] }; + const byAlias = new Map(owned.lock.packages.map((p) => [p.alias, p])); + const tx = { home: ctx.home, runtime: ctx.runtime, skillsDir: ctx.skillsDir, stagingDir: ctx.stagingDir, hooks: ctx.hooks, now: ctx.now() }; + for (const action of plan.actions) { + const existing = readReceipt(ctx.home, ctx.runtime, action.alias); + if (action.action === 'install' || action.action === 'update') { + const pkg = byAlias.get(action.alias)!; + const tree = trees.get(action.alias)!; + const owners = [...new Set([...(existing?.requiredBy ?? []), owned.owner])].sort(); + const receipt: InstallReceipt = { + schema: RECEIPT_SCHEMA, + runtime: ctx.runtime, + alias: pkg.alias, + package: pkg.package, + version: pkg.version, + releaseDigest: pkg.releaseDigest, + compositionDigest: pkg.compositionDigest, + overlayDigests: pkg.overlayDigests, + files: tree.files.map((f) => ({ path: f.path, sha256: f.sha256, mode: f.mode })), + requiredBy: owners, + installedAt: tx.now.toISOString(), + }; + const backup = action.action === 'update' ? action.backup : []; + const done = applyTreeChange(tx, { alias: action.alias, tree, receipt, previous: existing, preserve: action.preserve, backup }); + result.changed.push({ alias: action.alias, action: action.action, ...done }); + } else if (action.action === 'unchanged' && action.addOwner && existing) { + writeReceipt(ctx.home, { ...existing, requiredBy: [...existing.requiredBy, owned.owner].sort() }); + } else if (action.action === 'release' && existing) { + writeReceipt(ctx.home, { ...existing, requiredBy: action.remainingOwners }); + result.changed.push({ alias: action.alias, action: 'release', backupDir: null, preserved: [], displaced: [] }); + } else if (action.action === 'remove' && existing) { + result.removed.push(removeOwned(ctx.home, ctx.runtime, ctx.skillsDir, existing)); + } + } + const owners = readOwners(ctx.home, ctx.runtime); + if (uninstall) delete owners.owners[owned.owner]; + else owners.owners[owned.owner] = { ref: owned.ref, lock: owned.lock }; + writeOwners(ctx.home, ctx.runtime, owners); + return result; +} diff --git a/packages/cli/src/lib/install/install-errors.ts b/packages/cli/src/lib/install/install-errors.ts new file mode 100644 index 0000000..f4dad61 --- /dev/null +++ b/packages/cli/src/lib/install/install-errors.ts @@ -0,0 +1,24 @@ +import type { CliErrorCode } from '../output.js'; + +/** + * A classified installer failure. `code` is an envelope error code, so a + * command maps it to its exit code without parsing the message; `details` + * is machine-readable context (never file contents or credentials). + */ +export class InstallError extends Error { + constructor( + readonly code: CliErrorCode, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'InstallError'; + } +} + +export const usageError = (message: string, details?: unknown) => new InstallError('usage_error', message, details); +/** Bytes, signatures or digests do not match what was pinned. Nothing is written. */ +export const integrityError = (message: string, details?: unknown) => new InstallError('integrity_error', message, details); +/** Local state blocks the change (modified, unowned or shared files); nothing is written. */ +export const conflictError = (message: string, details?: unknown) => new InstallError('conflict', message, details); +export const incompatibleError = (message: string, details?: unknown) => new InstallError('incompatible', message, details); diff --git a/packages/cli/src/lib/install/install-operations.ts b/packages/cli/src/lib/install/install-operations.ts new file mode 100644 index 0000000..cd8d3ab --- /dev/null +++ b/packages/cli/src/lib/install/install-operations.ts @@ -0,0 +1,141 @@ +import { canonicalJsonBytes, lockDigest } from '@skillx/contracts'; +import type { InstallLock } from '@skillx/contracts'; +import { sha256Hex } from './fetch-verify.js'; +import { applyOwned, ownedLocks, planOwned, recover } from './install-engine.js'; +import type { ApplyResult, OwnedLock } from './install-engine.js'; +import type { InstallContext } from './install-context.js'; +import { withInstallLock } from './install-context.js'; +import { InstallError, conflictError } from './install-errors.js'; +import { listReceipts, readOwners } from './install-state.js'; +import type { InstallPlan } from './planner.js'; +import { checkRuntimes, parseRefs, resolveSource } from './resolver.js'; +import type { Resolution, SourceRef } from './resolver.js'; + +/** + * The installer operations behind `skillx install|update|uninstall|list` + * and the local MCP tools. Every mutating operation holds the install lock, + * recovers interrupted transactions first and then plans afresh. + */ + +export interface PlannedItem extends OwnedLock { + plan: InstallPlan; +} + +export interface SourcePlan { + lock: InstallLock; + lockDigest: string; + items: PlannedItem[]; + /** Digest over every item plan: what an MCP `apply` may pin. */ + planDigest: string; + warnings: string[]; +} + +const ownerRefOf = (ref: SourceRef, refs: readonly string[]) => (pkg: string) => + ref.kind === 'packages' ? refs[ref.packages.findIndex((p) => p.name === pkg)] ?? pkg : refs[0]!; + +export async function planResolution(ctx: InstallContext, resolution: Resolution, refFor: (pkg: string) => string, sourceRef: string): Promise { + checkRuntimes(resolution.lock.runtimes, ctx.runtime); + const items = ownedLocks(resolution.lock, resolution.overlays, refFor, sourceRef).map((owned) => ({ ...owned, plan: planOwned(ctx, owned) })); + return { + lock: resolution.lock, + lockDigest: await lockDigest(resolution.lock), + items, + planDigest: `sha256:${sha256Hex(canonicalJsonBytes(items.map((i) => i.plan.planDigest)))}`, + warnings: resolution.warnings, + }; +} + +/** Resolves refs and plans them without changing anything. */ +export async function planRefs(ctx: InstallContext, refs: readonly string[]): Promise { + const ref = parseRefs(refs); + return planResolution(ctx, await resolveSource(ctx.registry, ref), ownerRefOf(ref, refs), refs[0]!); +} + +export interface ApplyOutcome extends SourcePlan { + applied: ApplyResult[]; + recovered: { rolledBack: string[]; rolledForward: string[] }; +} + +/** Applies a source plan produced by `make` under the lock; `expectDigest` pins the plan the caller saw. */ +export async function applyPlanned(ctx: InstallContext, make: () => Promise, expectDigest?: string): Promise { + return withInstallLock(ctx, async () => { + const recovery = recover(ctx); + const planned = await make(); + if (expectDigest !== undefined && expectDigest !== planned.planDigest) { + throw conflictError('the plan changed since it was shown; plan again', { expected: expectDigest, actual: planned.planDigest }); + } + const conflicts = planned.items.flatMap((i) => i.plan.actions.filter((a) => a.action === 'conflict')); + if (conflicts.length > 0) throw conflictError(`${conflicts.length} skill(s) conflict with local files or other sources; nothing was changed`, { conflicts }); + const applied: ApplyResult[] = []; + for (const item of planned.items) applied.push(await applyOwned(ctx, item, item.plan)); + return { ...planned, applied, recovered: { rolledBack: recovery.rolledBack, rolledForward: recovery.rolledForward } }; + }); +} + +export const installRefs = (ctx: InstallContext, refs: readonly string[], expectDigest?: string) => applyPlanned(ctx, () => planRefs(ctx, refs), expectDigest); + +/** + * The owner keys refs install under: one for a kit or a collection, one per + * package (`packages:@scope/name`) for explicit packages, as `ownedLocks` + * splits them, so each package is updated and uninstalled on its own. + */ +export function ownersOfRefs(refs: readonly string[]): string[] { + const ref = parseRefs(refs); + if (ref.kind === 'kit') return [`kit:${ref.kit}`]; + if (ref.kind === 'collection') return [`collection:${ref.id}`]; + return [...new Set(ref.packages.map((p) => `packages:${p.name}`))]; +} + +/** + * Uninstalls each owner the refs name; every one must be installed, or nothing + * is removed. `onUninstalled` runs right after each owner is gone, so a caller + * keeping a lock file in step loses nothing when a later owner fails. + */ +export async function uninstallRef(ctx: InstallContext, refs: readonly string[], onUninstalled?: (owner: string) => void): Promise { + const owners = ownersOfRefs(refs); + return withInstallLock(ctx, async () => { + recover(ctx); + const records = readOwners(ctx.home, ctx.runtime).owners; + const missing = owners.filter((o) => records[o] === undefined); + if (missing.length > 0) throw new InstallError('not_found', `${missing.join(', ')} not installed for ${ctx.runtime}`, { owners: missing }); + const results: ApplyResult[] = []; + for (const owner of owners) { + const record = records[owner]!; + const owned = { owner, ref: record.ref, lock: { ...record.lock, packages: [] }, overlays: new Map() }; + results.push(await applyOwned(ctx, owned, planOwned(ctx, owned), true)); + onUninstalled?.(owner); + } + return results; + }); +} + +/** Re-resolves the named installed sources (all when none is named) and applies the changes. */ +export async function updateInstalled(ctx: InstallContext, refs: readonly string[]): Promise { + const records = readOwners(ctx.home, ctx.runtime).owners; + const wanted = refs.length > 0 ? ownersOfRefs(refs) : Object.keys(records).sort(); + const outcomes: ApplyOutcome[] = []; + for (const owner of wanted) { + const record = records[owner]; + if (record === undefined) throw new InstallError('not_found', `${owner} is not installed for ${ctx.runtime}`, { owner }); + outcomes.push(await installRefs(ctx, [record.ref])); + } + return outcomes; +} + +export function listInstalled(ctx: InstallContext) { + const owners = readOwners(ctx.home, ctx.runtime).owners; + return { + runtime: ctx.runtime, + skillsDir: ctx.skillsDir, + skills: listReceipts(ctx.home, ctx.runtime).map((r) => ({ + alias: r.alias, + package: r.package, + version: r.version, + compositionDigest: r.compositionDigest, + requiredBy: r.requiredBy, + })), + sources: Object.entries(owners) + .map(([owner, record]) => ({ owner, ref: record.ref, packages: record.lock.packages.map((p) => p.alias) })) + .sort((a, b) => (a.owner < b.owner ? -1 : 1)), + }; +} diff --git a/packages/cli/src/lib/install/install-safety.test.ts b/packages/cli/src/lib/install/install-safety.test.ts new file mode 100644 index 0000000..1fa07e2 --- /dev/null +++ b/packages/cli/src/lib/install/install-safety.test.ts @@ -0,0 +1,148 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { INSTALL_LOCK_SCHEMA, INSTALL_RESOLVER_VERSION, isInstallAlias } from '@skillx/contracts'; +import type { InstallLock } from '@skillx/contracts'; +import { sha256Hex } from './fetch-verify.js'; +import { RECEIPT_SCHEMA, readReceipt, writeReceipt } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; +import { assertLockScope, dropFromLockFile, mergeLock, writeLockFile } from './lock-file.js'; +import { planInstall } from './planner.js'; +import { assertSecureRegistry } from './registry-client.js'; +import { removeOwned } from './remove-owned.js'; +import { containedPath, renameWithRetry } from './safe-fs.js'; +import { memberDrift } from './sync-locked.js'; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'skillx-safety-')); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const D = (c: string) => `sha256:${c.repeat(64)}`; +const pkg = (name: string, version = '1.0.0') => ({ alias: name, package: `@alpha/${name}`, version, releaseDigest: D('a'), compositionDigest: D('a'), overlayDigests: [] }); +const lock = (packages = [pkg('demo')], source: InstallLock['source'] = { kind: 'packages' }): InstallLock => ({ schema: INSTALL_LOCK_SCHEMA, resolver: INSTALL_RESOLVER_VERSION, source, runtimes: {}, packages }); + +function write(path: string, text: string): string { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, text); + return sha256Hex(new TextEncoder().encode(text)); +} +function receipt(alias: string, files: Record): InstallReceipt { + return { + schema: RECEIPT_SCHEMA, runtime: 'claude-code', alias, package: `@alpha/${alias}`, version: '1.0.0', releaseDigest: D('a'), compositionDigest: D('a'), overlayDigests: [], + files: Object.entries(files).map(([path, sha256]) => ({ path, sha256, mode: '100644' as const })), requiredBy: ['packages:@alpha/demo'], installedAt: '2026-10-19T00:00:00.000Z', + }; +} +/** Junctions need no privilege on Windows; elsewhere the type is ignored and a symlink is made. */ +const link = (target: string, path: string) => symlinkSync(target, path, 'junction'); +const fileLinksWork = (() => { + const dir = mkdtempSync(join(tmpdir(), 'skillx-probe-')); + try { + writeFileSync(join(dir, 'a'), ''); + symlinkSync(join(dir, 'a'), join(dir, 'b'), 'file'); + return true; + } catch { + return false; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); + +describe('uninstall never follows links', () => { + it('keeps files reached through a linked subdirectory', () => { + const home = join(root, '.skillx'); + const skill = join(root, 'skills', 'demo'); + const shaSkill = write(join(skill, 'SKILL.md'), '# demo\n'); + const shaGuide = write(join(root, 'outside', 'guide.md'), 'guide\n'); + link(join(root, 'outside'), join(skill, 'references')); + const r = receipt('demo', { 'SKILL.md': shaSkill, 'references/guide.md': shaGuide }); + writeReceipt(home, r); + + const result = removeOwned(home, 'claude-code', join(root, 'skills'), r); + expect(result).toMatchObject({ removed: ['SKILL.md'], keptModified: ['references/guide.md'], keptUnknown: ['references'] }); + expect(readFileSync(join(root, 'outside', 'guide.md'), 'utf8')).toBe('guide\n'); + expect(readReceipt(home, 'claude-code', 'demo')).toBeNull(); + }); + + it('leaves a linked skill directory and everything behind it alone', () => { + const home = join(root, '.skillx'); + const sha = write(join(root, 'elsewhere', 'SKILL.md'), '# demo\n'); + mkdirSync(join(root, 'skills'), { recursive: true }); + link(join(root, 'elsewhere'), join(root, 'skills', 'demo')); + const result = removeOwned(home, 'claude-code', join(root, 'skills'), receipt('demo', { 'SKILL.md': sha })); + expect(result).toMatchObject({ removed: [], keptUnknown: ['.'] }); + expect(existsSync(join(root, 'elsewhere', 'SKILL.md'))).toBe(true); + }); + + it.skipIf(!fileLinksWork)('keeps a file symlink even when its target matches the receipt', () => { + const sha = write(join(root, 'outside.md'), '# demo\n'); + mkdirSync(join(root, 'skills', 'demo'), { recursive: true }); + symlinkSync(join(root, 'outside.md'), join(root, 'skills', 'demo', 'SKILL.md'), 'file'); + const result = removeOwned(join(root, '.skillx'), 'claude-code', join(root, 'skills'), receipt('demo', { 'SKILL.md': sha })); + expect(result.keptModified).toEqual(['SKILL.md']); + expect(existsSync(join(root, 'outside.md'))).toBe(true); + }); + + it('plans a conflict for a linked skill directory, even with --force and a receipt', () => { + mkdirSync(join(root, 'elsewhere')); + mkdirSync(join(root, 'skills')); + link(join(root, 'elsewhere'), join(root, 'skills', 'demo')); + const home = join(root, '.skillx'); + writeReceipt(home, receipt('demo', {})); + for (const force of [false, true]) { + const plan = planInstall({ home, runtime: 'claude-code', skillsDir: join(root, 'skills'), owner: 'packages:@alpha/demo', packages: [pkg('demo', '2.0.0')], owners: { schema: 'skillx.install-owners/v1', owners: {} }, force }); + expect(plan.actions).toEqual([expect.objectContaining({ action: 'conflict', reason: 'unowned_directory', files: ['.'] })]); + } + }); +}); + +describe('path and name guards', () => { + it('refuses traversal, absolute and drive paths', () => { + for (const bad of ['../../evil.txt', 'a/../../b', '/etc/passwd', 'C:/x', 'a\\..\\b', 'a//b', '.', '']) expect(() => containedPath(root, bad)).toThrow(/unsafe path/); + expect(containedPath(root, 'references/guide.md')).toBe(join(root, 'references', 'guide.md')); + }); + + it('rejects aliases ending in a dot and Windows device names', () => { + for (const bad of ['demo.', 'con', 'nul', 'com1', 'lpt9', 'aux.md', 'Demo']) expect(isInstallAlias(bad)).toBe(false); + for (const good of ['demo', 'my.skill', 'console', 'com10', 'a']) expect(isInstallAlias(good)).toBe(true); + }); + + it('allows https anywhere and plain http only to this machine', () => { + for (const ok of ['https://skillx.sh', 'http://localhost:5173', 'http://127.0.0.1:8787', 'http://[::1]:5173', 'http://app.localhost']) expect(() => assertSecureRegistry(ok)).not.toThrow(); + for (const bad of ['http://skillx.sh', 'http://10.0.0.5', 'ftp://skillx.sh', 'nope']) expect(() => assertSecureRegistry(bad)).toThrow(); + }); + + it('retries a busy rename, then gives up with the last error', () => { + let calls = 0; + const busy = Object.assign(new Error('busy'), { code: 'EBUSY' }); + renameWithRetry('a', 'b', { rename: () => { calls += 1; if (calls < 3) throw busy; } }); + expect(calls).toBe(3); + expect(() => renameWithRetry('a', 'b', { rename: () => { throw busy; } })).toThrow('busy'); + }); +}); + +describe('collection lock drift', () => { + it('requires exactly the members, at their pinned version or digest', () => { + const l = lock([pkg('demo', '1.0.0'), pkg('tools', '1.1.0')]); + expect(memberDrift([{ packageName: '@alpha/demo', version: '1.0.0' }, { packageName: '@alpha/tools' }], l)).toEqual([]); + expect(memberDrift([{ packageName: '@alpha/demo', version: '1.0.1' }, { packageName: '@alpha/tools' }], l)).toEqual(['@alpha/demo: member pins 1.0.1, lock has 1.0.0']); + expect(memberDrift([{ packageName: '@alpha/demo', digest: D('b') }], l)).toEqual(['@alpha/demo: member pins another release digest', '@alpha/tools: locked but not a member']); + expect(memberDrift([{ packageName: '@alpha/demo' }, { packageName: '@alpha/tools' }, { packageName: '@alpha/new' }], l)).toEqual(['@alpha/new: member not locked']); + }); +}); + +describe('lock file scope', () => { + it('merges packages, refuses another scope, and drops uninstalled packages', () => { + const path = join(root, 'skillx.lock.json'); + writeLockFile(path, lock([pkg('demo')])); + const merged = mergeLock(lock([pkg('demo')]), lock([pkg('tools'), pkg('demo', '1.1.0')])); + expect(merged.packages.map((p) => `${p.alias}@${p.version}`)).toEqual(['demo@1.1.0', 'tools@1.0.0']); + writeLockFile(path, merged); + expect(() => assertLockScope(path, 'kit:@alpha/my-kit')).toThrow(/locks packages/); + expect(() => assertLockScope(path, 'packages')).not.toThrow(); + dropFromLockFile(path, '@alpha/demo'); + expect(JSON.parse(readFileSync(path, 'utf8')).packages.map((p: { alias: string }) => p.alias)).toEqual(['tools']); + }); +}); diff --git a/packages/cli/src/lib/install/install-state.ts b/packages/cli/src/lib/install/install-state.ts new file mode 100644 index 0000000..9bc708f --- /dev/null +++ b/packages/cli/src/lib/install/install-state.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto'; +import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { InstallLock } from '@skillx/contracts'; +import { sha256Hex } from './fetch-verify.js'; +import { InstallError } from './install-errors.js'; + +/** + * Local installer state under ``: + * + * - `receipts//.json` (`skillx.install-receipt/v1`): what one + * installed skill directory holds (every owned file with its SHA-256) and + * which sources require it (`requiredBy`, the ref-count). + * - `owners/.json` (`skillx.install-owners/v1`): per source, the ref + * it was installed from and its lock, so update and uninstall know which + * skills a source brought in. + * + * Files are written to a temporary name and renamed over the old one, so a + * crash leaves either the old or the new document, never a torn one. + */ + +export const RECEIPT_SCHEMA = 'skillx.install-receipt/v1'; +export const OWNERS_SCHEMA = 'skillx.install-owners/v1'; + +export interface ReceiptFile { + path: string; + sha256: string; + mode: '100644' | '100755'; +} + +export interface InstallReceipt { + schema: typeof RECEIPT_SCHEMA; + runtime: string; + alias: string; + package: string; + version: string; + releaseDigest: string; + compositionDigest: string; + overlayDigests: string[]; + files: ReceiptFile[]; + /** Owner keys of the sources that need this skill, sorted. Empty means removable. */ + requiredBy: string[]; + installedAt: string; +} + +export interface OwnerRecord { + ref: string; + lock: InstallLock; +} + +export interface OwnersFile { + schema: typeof OWNERS_SCHEMA; + owners: Record; +} + +export function writeJsonAtomic(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${randomUUID()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); + renameSync(tmp, path); +} + +function readJson(path: string): T | null { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + throw new InstallError('unexpected_error', `${path} is corrupt; move it aside and run the command again`); + } +} + +const receiptsDir = (home: string, runtime: string) => join(home, 'receipts', runtime); +const receiptPath = (home: string, runtime: string, alias: string) => join(receiptsDir(home, runtime), `${alias}.json`); +const ownersPath = (home: string, runtime: string) => join(home, 'owners', `${runtime}.json`); + +export const readReceipt = (home: string, runtime: string, alias: string) => readJson(receiptPath(home, runtime, alias)); +export const writeReceipt = (home: string, receipt: InstallReceipt) => writeJsonAtomic(receiptPath(home, receipt.runtime, receipt.alias), receipt); +export const deleteReceipt = (home: string, runtime: string, alias: string) => rmSync(receiptPath(home, runtime, alias), { force: true }); + +export function listReceipts(home: string, runtime: string): InstallReceipt[] { + let names: string[]; + try { + names = readdirSync(receiptsDir(home, runtime)); + } catch { + return []; + } + return names + .filter((n) => n.endsWith('.json')) + .map((n) => readReceipt(home, runtime, n.slice(0, -'.json'.length))) + .filter((r): r is InstallReceipt => r !== null) + .sort((a, b) => (a.alias < b.alias ? -1 : 1)); +} + +export function readOwners(home: string, runtime: string): OwnersFile { + return readJson(ownersPath(home, runtime)) ?? { schema: OWNERS_SCHEMA, owners: {} }; +} + +export function writeOwners(home: string, runtime: string, owners: OwnersFile): void { + writeJsonAtomic(ownersPath(home, runtime), owners); +} + +export interface LocalFile { + path: string; + sha256: string; +} + +/** Regular files under `dir` with POSIX relative paths. Links and other non-files are listed with an empty hash. */ +export function scanDir(dir: string): LocalFile[] { + const out: LocalFile[] = []; + const walk = (abs: string, rel: string) => { + let entries: string[]; + try { + entries = readdirSync(abs); + } catch { + return; + } + for (const name of entries) { + const path = join(abs, name); + const relPath = rel === '' ? name : `${rel}/${name}`; + const stat = lstatSync(path); + if (stat.isDirectory()) walk(path, relPath); + else out.push({ path: relPath, sha256: stat.isFile() ? sha256Hex(readFileSync(path)) : '' }); + } + }; + walk(dir, ''); + return out.sort((a, b) => (a.path < b.path ? -1 : 1)); +} + +export interface LocalState { + exists: boolean; + /** Receipt files whose content changed. */ + modified: string[]; + /** Receipt files that are gone. */ + missing: string[]; + /** Files the receipt does not list (all files when there is no receipt). */ + unknown: string[]; + /** Symlinks, junctions and other entries that are not regular files or directories. */ + links: string[]; +} + +export function inspectLocal(dir: string, receipt: InstallReceipt | null): LocalState { + let isDir: boolean; + try { + isDir = lstatSync(dir).isDirectory(); + } catch { + return { exists: false, modified: [], missing: receipt?.files.map((f) => f.path) ?? [], unknown: [], links: [] }; + } + // A file or link where the skill directory belongs is never ours to replace. + if (!isDir) return { exists: true, modified: [], missing: [], unknown: ['.'], links: ['.'] }; + const files = scanDir(dir); + const links = files.filter((f) => f.sha256 === '').map((f) => f.path); + const local = new Map(files.map((f) => [f.path, f.sha256])); + const owned = new Map((receipt?.files ?? []).map((f) => [f.path, f.sha256])); + const modified: string[] = []; + const missing: string[] = []; + for (const [path, sha] of owned) { + const now = local.get(path); + if (now === undefined) missing.push(path); + else if (now !== sha) modified.push(path); + } + const unknown = [...local.keys()].filter((p) => !owned.has(p)); + return { exists: true, modified, missing, unknown, links }; +} diff --git a/packages/cli/src/lib/install/install-units.test.ts b/packages/cli/src/lib/install/install-units.test.ts new file mode 100644 index 0000000..f7483ed --- /dev/null +++ b/packages/cli/src/lib/install/install-units.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { INSTALL_LOCK_SCHEMA, INSTALL_RESOLVER_VERSION } from '@skillx/contracts'; +import type { InstallLock } from '@skillx/contracts'; +import { claudeCodeAdapter, codexAdapter, getAdapter, skillxHome, stagingDir } from './adapters.js'; +import { writeLockFile } from './lock-file.js'; +import { planInstall } from './planner.js'; +import { newestVersion } from './release-pins.js'; +import { parseRefs } from './resolver.js'; +import { loadTrustedKeys } from './trusted-keys.js'; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'skillx-units-')); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const D = (c: string) => `sha256:${c.repeat(64)}`; +const lock = (extra: Partial = {}): InstallLock => ({ + schema: INSTALL_LOCK_SCHEMA, + resolver: INSTALL_RESOLVER_VERSION, + source: { kind: 'packages' }, + runtimes: {}, + packages: [{ alias: 'demo', package: '@alpha/demo', version: '1.0.0', releaseDigest: D('a'), compositionDigest: D('a'), overlayDigests: [] }], + ...extra, +}); + +describe('adapters', () => { + it('derive every location from the given environment', () => { + const env = { HOME: root, USERPROFILE: root }; + expect(claudeCodeAdapter.skillsDir(env)).toBe(join(root, '.claude', 'skills')); + expect(codexAdapter.skillsDir(env)).toBe(join(root, '.codex', 'skills')); + expect(codexAdapter.skillsDir({ ...env, CODEX_HOME: join(root, 'cx') })).toBe(join(root, 'cx', 'skills')); + expect(claudeCodeAdapter.skillsDir({ ...env, CLAUDE_CONFIG_DIR: join(root, 'cc') })).toBe(join(root, 'cc', 'skills')); + expect(skillxHome(env)).toBe(join(root, '.skillx')); + expect(skillxHome({ ...env, SKILLX_HOME: join(root, 'sx') })).toBe(join(root, 'sx')); + expect(stagingDir(codexAdapter, env)).toBe(join(root, '.codex', '.skillx-staging')); + expect(() => getAdapter('cursor')).toThrow(/unknown runtime/); + }); +}); + +describe('refs and versions', () => { + it('parses kit, collection and package refs', () => { + const rev = 'rev_00000000-0000-4000-8000-000000000000'; + const col = 'col_00000000-0000-4000-8000-000000000000'; + expect(parseRefs([`kit:@alpha/my-kit#${rev}`])).toEqual({ kind: 'kit', kit: '@alpha/my-kit', revisionId: rev }); + expect(parseRefs([`collection:${col}#3`])).toEqual({ kind: 'collection', id: col, revision: 3 }); + expect(parseRefs([`collection:${col}`])).toEqual({ kind: 'collection', id: col, revision: 'latest' }); + expect(parseRefs(['@alpha/demo@1.2.0', '@alpha/tools'])).toEqual({ kind: 'packages', packages: [{ name: '@alpha/demo', version: '1.2.0' }, { name: '@alpha/tools' }] }); + expect(() => parseRefs(['demo'])).toThrow(/is not/); + expect(() => parseRefs([`kit:@alpha/my-kit`, '@alpha/demo'])).toThrow(/one kit/); + }); + + it('prefers the newest stable version', () => { + expect(newestVersion(['1.2.0', '1.10.0', '2.0.0-beta.1'])).toBe('1.10.0'); + expect(newestVersion(['2.0.0-beta.1', '2.0.0-alpha.3'])).toBe('2.0.0-beta.1'); + expect(newestVersion([])).toBeNull(); + }); +}); + +describe('trusted keys', () => { + it('merges the file and SKILLX_TRUSTED_KEYS and rejects malformed keys', () => { + const key = Buffer.alloc(32, 7).toString('base64'); + writeFileSync(join(root, 'trusted-keys.json'), JSON.stringify({ keys: { 'file-key': key } })); + const keys = loadTrustedKeys(root, { SKILLX_TRUSTED_KEYS: `env-key=${key}` }); + expect([...keys.keys()].sort()).toEqual(['env-key', 'file-key']); + expect(loadTrustedKeys(join(root, 'none'), {}).size).toBe(0); + expect(() => loadTrustedKeys(root, { SKILLX_TRUSTED_KEYS: 'short=AAAA' })).toThrow(/32-byte/); + }); +}); + +describe('lock file', () => { + it('writes a clean lock and refuses one that carries a secret or an absolute path', () => { + const path = writeLockFile(join(root, 'skillx.lock.json'), lock()); + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(lock()); + expect(() => writeLockFile(join(root, 'x.json'), lock({ runtimes: { 'claude-code': '/home/alice/.claude' } }))).toThrow(/leaking/); + expect(() => writeLockFile(join(root, 'x.json'), lock({ runtimes: { codex: 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' } }))).toThrow(/leaking/); + }); +}); + +describe('planner', () => { + const plan = (force: boolean) => + planInstall({ home: join(root, '.skillx'), runtime: 'claude-code', skillsDir: join(root, 'skills'), owner: 'packages:@alpha/demo', packages: lock().packages, owners: { schema: 'skillx.install-owners/v1', owners: {} }, force }); + + it('installs into an empty slot, and treats an unowned directory as a conflict unless forced', () => { + expect(plan(false).actions).toEqual([expect.objectContaining({ action: 'install', alias: 'demo', preserve: [] })]); + mkdirSync(join(root, 'skills', 'demo'), { recursive: true }); + writeFileSync(join(root, 'skills', 'demo', 'notes.md'), 'mine'); + expect(plan(false)).toMatchObject({ conflicts: 1, actions: [{ action: 'conflict', reason: 'unowned_directory', files: ['notes.md'] }] }); + expect(plan(true).actions).toEqual([expect.objectContaining({ action: 'install', preserve: ['notes.md'] })]); + expect(plan(true).planDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + }); +}); diff --git a/packages/cli/src/lib/install/journal-recovery.ts b/packages/cli/src/lib/install/journal-recovery.ts new file mode 100644 index 0000000..5ec2b5d --- /dev/null +++ b/packages/cli/src/lib/install/journal-recovery.ts @@ -0,0 +1,123 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeReceipt } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; +import { renameWithRetry } from './safe-fs.js'; +import type { RenameHooks } from './safe-fs.js'; +import { retireOld } from './tree-proof.js'; + +/** + * Finishes skill-directory transactions a crash or a refused rename left + * open. While the stage still exists the swap has not happened, so it rolls + * back (restores the old tree, drops the stage); once the stage is gone the + * new tree is in place, so it rolls forward (writes the receipt, retires the + * old tree). An old tree is never deleted unproven: when it cannot go back + * because something else now sits at the target, it is parked in the backup + * area and reported. Staging entries no journal names are leftovers of a + * crash before the journal was written; an old tree among them is parked too. + */ + +export interface Journal { + schema: 'skillx.install-journal/v2'; + alias: string; + target: string; + stage: string; + backup: string; + /** Backup area of this change: displaced files land here. */ + saved: string; + previous: InstallReceipt | null; + receipt: InstallReceipt; +} + +export interface Recovery { + rolledBack: string[]; + rolledForward: string[]; + sweptStaging: number; + /** Old trees that could not be restored, and where they were saved. */ + parked: Array<{ alias: string; savedTo: string }>; +} + +export interface RecoveryContext { + home: string; + runtime: string; + stagingDir: string; + hooks?: RenameHooks; +} + +export const journalDir = (ctx: Pick) => join(ctx.home, 'journal', ctx.runtime); + +export const exists = (path: string) => { + try { + lstatSync(path); + return true; + } catch { + return false; + } +}; + +/** + * Moves a whole old tree into the backup area. A rename keeps it exactly as + * it was, links included. Across volumes (or when the rename is still refused + * after retries) it is copied file by file instead: + * regular files are copied, and links (junctions included) are recorded as + * `.link` and removed, never followed or recreated. + */ +function park(ctx: RecoveryContext, tree: string, dest: string): string { + let to = dest; + for (let n = 1; exists(to); n += 1) to = `${dest}.${n}`; + mkdirSync(join(to, '..'), { recursive: true }); + try { + renameWithRetry(tree, to, ctx.hooks); + } catch { + // Another volume (EXDEV), or a rename still refused after retries. + retireOld({ dir: tree, previous: null, carried: join(to, '.none'), saved: to }); + } + return to; +} + +export function recoverJournals(ctx: RecoveryContext): Recovery { + const result: Recovery = { rolledBack: [], rolledForward: [], sweptStaging: 0, parked: [] }; + const dir = journalDir(ctx); + const referenced = new Set(); + for (const name of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.json')) : []) { + const path = join(dir, name); + let journal: Journal; + try { + journal = JSON.parse(readFileSync(path, 'utf8')) as Journal; + } catch { + // A journal is written atomically, so an unreadable one is a temp leftover. + rmSync(path, { force: true }); + continue; + } + const restore = () => { + if (!exists(journal.backup)) return; + if (!exists(journal.target)) renameWithRetry(journal.backup, journal.target, ctx.hooks); + else result.parked.push({ alias: journal.alias, savedTo: park(ctx, journal.backup, `${journal.saved}-previous-tree`) }); + }; + if (exists(journal.stage)) { + restore(); + rmSync(journal.stage, { recursive: true, force: true }); + result.rolledBack.push(journal.alias); + } else if (exists(journal.target)) { + writeReceipt(ctx.home, journal.receipt); + retireOld({ dir: journal.backup, previous: journal.previous ?? null, carried: journal.target, saved: journal.saved }); + result.rolledForward.push(journal.alias); + } else if (exists(journal.backup)) { + restore(); + result.rolledBack.push(journal.alias); + } + referenced.add(journal.stage).add(journal.backup); + rmSync(path, { force: true }); + } + for (const name of existsSync(ctx.stagingDir) ? readdirSync(ctx.stagingDir) : []) { + const path = join(ctx.stagingDir, name); + if (referenced.has(path)) continue; + if (name.endsWith('.old')) { + const alias = name.replace(/\.[0-9a-f-]{36}\.old$/, ''); + result.parked.push({ alias, savedTo: park(ctx, path, join(ctx.home, 'backups', ctx.runtime, alias, `unjournaled-${name}`)) }); + } else rmSync(path, { recursive: true, force: true }); + result.sweptStaging += 1; + } + for (const name of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.tmp')) : []) rmSync(join(dir, name), { force: true }); + return result; +} diff --git a/packages/cli/src/lib/install/local-mcp.ts b/packages/cli/src/lib/install/local-mcp.ts new file mode 100644 index 0000000..924d57c --- /dev/null +++ b/packages/cli/src/lib/install/local-mcp.ts @@ -0,0 +1,120 @@ +import { createInterface } from 'node:readline'; +import { ADAPTERS } from './adapters.js'; +import type { InstallContext } from './install-context.js'; +import { InstallError } from './install-errors.js'; +import { applyPlanned, listInstalled, planRefs } from './install-operations.js'; +import type { SourcePlan } from './install-operations.js'; +import { parseLock, planLocked } from './sync-locked.js'; + +/** + * `skillx mcp`: a local MCP server over stdio (newline-delimited JSON-RPC + * 2.0) with three tools. `inspect` and `plan` only read. `apply` changes + * files and therefore requires `confirm: true` and the `planDigest` of the plan + * the user approved; it refuses unless the fresh plan is exactly that one. + * `force` is CLI-only: an agent can never overwrite local edits. + */ + +export interface McpContextArgs { + runtime?: string; +} + +type Json = Record; + +const SOURCE_SCHEMA = { + source: { type: 'string', description: 'kit:@handle/kit[#revisionId], collection:[#n] or @publisher/package[@version] (space separated for several packages)' }, + lock: { type: 'object', description: 'a skillx.lock/v1 document to restore exactly (instead of source)' }, + runtime: { type: 'string', enum: ADAPTERS.map((a) => a.id), description: 'target runtime (default claude-code)' }, +}; + +export const MCP_TOOLS = [ + { name: 'inspect', description: 'List runtimes, their skills directories, installed skills and their sources. Read-only.', inputSchema: { type: 'object', properties: { runtime: SOURCE_SCHEMA.runtime }, additionalProperties: false } }, + { name: 'plan', description: 'Resolve a source or lock and show what apply would change, with a planDigest. Read-only.', inputSchema: { type: 'object', properties: SOURCE_SCHEMA, additionalProperties: false } }, + { + name: 'apply', + description: 'Install or restore a source or lock. Changes files: requires confirm: true and the planDigest from plan; refuses when the fresh plan differs. Local edits are never overwritten here (use the CLI with --force).', + inputSchema: { + type: 'object', + properties: { ...SOURCE_SCHEMA, confirm: { type: 'boolean', const: true }, planDigest: { type: 'string', description: 'the planDigest plan returned' } }, + required: ['confirm', 'planDigest'], + additionalProperties: false, + }, + }, +]; + +const view = (p: SourcePlan) => ({ lock: p.lock, lockDigest: p.lockDigest, planDigest: p.planDigest, warnings: p.warnings, items: p.items.map((i) => ({ owner: i.owner, ref: i.ref, plan: i.plan })) }); + +function planner(ctx: InstallContext, args: Json): () => Promise { + if (args.lock !== undefined) { + const lock = parseLock(args.lock); + return () => planLocked(ctx, lock); + } + if (typeof args.source !== 'string' || args.source.trim() === '') throw new InstallError('usage_error', 'pass source or lock'); + const refs = args.source.trim().split(/\s+/); + return () => planRefs(ctx, refs); +} + +export async function callTool(name: string, args: Json, makeContext: (a: McpContextArgs) => InstallContext): Promise { + if (args.force !== undefined) throw new InstallError('usage_error', 'force is not available over MCP; ask the user to run the skillx CLI with --force'); + const ctx = () => makeContext(typeof args.runtime === 'string' ? { runtime: args.runtime } : {}); + if (name === 'inspect') { + const c = ctx(); + return { runtimes: ADAPTERS.map((a) => ({ id: a.id, skillsDir: a.skillsDir(c.env) })), installed: listInstalled(c), trustedKeyIds: [...c.keys.keys()] }; + } + if (name === 'plan') return view(await planner(ctx(), args)()); + if (name === 'apply') { + if (args.confirm !== true) throw new InstallError('usage_error', 'apply changes files on this machine; call it again with confirm: true after the user approves the plan'); + if (typeof args.planDigest !== 'string') throw new InstallError('usage_error', 'apply needs the planDigest of the plan the user approved; call plan first'); + const c = ctx(); + const outcome = await applyPlanned(c, planner(c, args), args.planDigest); + return { ...view(outcome), applied: outcome.applied, recovered: outcome.recovered }; + } + throw new InstallError('usage_error', `unknown tool ${name}`); +} + +/** Answers one JSON-RPC message; null for notifications. */ +export async function handleMessage(message: Json, makeContext: (a: McpContextArgs) => InstallContext, version: string): Promise { + const id = message.id; + if (id === undefined || id === null) return null; + const reply = (result: unknown) => ({ jsonrpc: '2.0', id, result }); + const params = (message.params ?? {}) as Json; + switch (message.method) { + case 'initialize': + return reply({ protocolVersion: typeof params.protocolVersion === 'string' ? params.protocolVersion : '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'skillx-local', version } }); + case 'ping': + return reply({}); + case 'tools/list': + return reply({ tools: MCP_TOOLS }); + case 'tools/call': { + try { + const data = await callTool(String(params.name), (params.arguments ?? {}) as Json, makeContext); + return reply({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: data, isError: false }); + } catch (error) { + const failure = error instanceof InstallError ? { code: error.code, message: error.message, details: error.details } : { code: 'unexpected_error', message: error instanceof Error ? error.message : String(error) }; + return reply({ content: [{ type: 'text', text: JSON.stringify({ error: failure }, null, 2) }], structuredContent: { error: failure }, isError: true }); + } + } + default: + return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${String(message.method)}` } }; + } +} + +/** Serves MCP on stdin/stdout until stdin closes. Logs never go to stdout. */ +export function serveStdio(makeContext: (a: McpContextArgs) => InstallContext, version: string): Promise { + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); + let queue = Promise.resolve(); + rl.on('line', (line) => { + if (line.trim() === '') return; + queue = queue.then(async () => { + let message: Json; + try { + message = JSON.parse(line) as Json; + } catch { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })}\n`); + return; + } + const answer = await handleMessage(message, makeContext, version); + if (answer !== null) process.stdout.write(`${JSON.stringify(answer)}\n`); + }); + }); + return new Promise((resolve) => rl.on('close', () => void queue.then(() => resolve()))); +} diff --git a/packages/cli/src/lib/install/lock-file.ts b/packages/cli/src/lib/install/lock-file.ts new file mode 100644 index 0000000..d21248d --- /dev/null +++ b/packages/cli/src/lib/install/lock-file.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { lockOwnerKey, sortLockPackages, validateInstallLock } from '@skillx/contracts'; +import type { InstallLock } from '@skillx/contracts'; +import { usageError } from './install-errors.js'; +import { writeJsonAtomic } from './install-state.js'; +import { parseLock } from './sync-locked.js'; + +/** + * `skillx.lock.json` on disk. A lock is validated before it is written and + * when it is read; one that would carry a secret or an absolute local path + * is refused, never written, so the file is always safe to commit or sync. + * + * A lock file has one scope: one kit, one collection, or a set of explicit + * packages. It always describes everything installed in that scope: + * installing more packages merges them into a packages lock, reinstalling + * or updating a kit or collection replaces its lock, and uninstalling a + * package drops it. Installing a source of another scope into the same file + * is refused before anything is installed (use another `--lock` file). + */ + +export function writeLockFile(path: string, lock: InstallLock): string { + const verdict = validateInstallLock(lock); + if (!verdict.valid) throw usageError('refusing to write an invalid or leaking lock', { issues: verdict.issues.map((i) => ({ path: i.path, code: i.code })) }); + const target = resolve(path); + writeJsonAtomic(target, lock); + return target; +} + +export function readLockFile(path: string): InstallLock { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw usageError(`cannot read the lock: ${error instanceof Error ? error.message : String(error)}`); + } + return parseLock(raw); +} + +/** The lock at `path`, or null when there is none yet. */ +export const existingLock = (path: string): InstallLock | null => (existsSync(path) ? readLockFile(path) : null); + +/** `packages`, or the owner key of the kit or collection a lock pins. */ +export const lockScope = (lock: Pick) => (lock.source.kind === 'packages' ? 'packages' : lockOwnerKey(lock.source, lock.packages)); + +/** Refuses, before anything is installed, a source of another scope than the lock file at `path`. */ +export function assertLockScope(path: string, scope: string): void { + const existing = existingLock(path); + if (existing !== null && lockScope(existing) !== scope) { + throw usageError(`${path} locks ${lockScope(existing)}, not ${scope}; pass --lock or --no-lock`, { lock: path }); + } +} + +/** `existing` updated with `next`: packages locks merge by alias, other scopes are replaced. */ +export function mergeLock(existing: InstallLock | null, next: InstallLock): InstallLock { + if (existing === null || next.source.kind !== 'packages') return next; + const byAlias = new Map(existing.packages.map((p) => [p.alias, p])); + for (const p of next.packages) byAlias.set(p.alias, p); + return { ...next, packages: sortLockPackages([...byAlias.values()]) }; +} + +/** Drops `packageName` from a packages lock file; other scopes are left as they are. */ +export function dropFromLockFile(path: string, packageName: string): string | null { + const existing = existingLock(path); + if (existing === null || existing.source.kind !== 'packages') return null; + const packages = existing.packages.filter((p) => p.package !== packageName); + if (packages.length === existing.packages.length) return null; + return writeLockFile(path, { ...existing, packages }); +} diff --git a/packages/cli/src/lib/install/planner.ts b/packages/cli/src/lib/install/planner.ts new file mode 100644 index 0000000..48cef33 --- /dev/null +++ b/packages/cli/src/lib/install/planner.ts @@ -0,0 +1,116 @@ +import { join } from 'node:path'; +import { canonicalJsonBytes } from '@skillx/contracts'; +import type { InstallLock, InstallLockPackage } from '@skillx/contracts'; +import { sha256Hex } from './fetch-verify.js'; +import { inspectLocal, readReceipt } from './install-state.js'; +import type { InstallReceipt, LocalState, OwnersFile } from './install-state.js'; + +/** + * Plans what applying a lock for one owner changes in one skills directory. + * Planning reads local state only; it never fetches or writes. Conflicts are + * explicit actions, and a plan with any conflict is not applied: + * + * - `local_modified`: an owned file changed or vanished (use `--force` to + * reinstall; changed files are backed up first). + * - `unowned_directory`: the directory exists without a receipt (`--force` + * installs over it and keeps every file that is not part of the release). + * - `shared_version`: another source needs this skill at another digest. + * Never forced: one directory cannot hold two versions. + * + * Files that are not owned (`unknown`) are always preserved and reported. + * A file, symlink or junction where a skill directory belongs is always an + * `unowned_directory` conflict: the installer never writes through a link. + * A symlink or junction inside the skill directory is a `contains_link` + * conflict, also with `--force`, but only when the directory would be + * rewritten (install or update): links are never copied or followed. + */ + +export type PlanAction = + | { action: 'install'; alias: string; package: string; version: string; compositionDigest: string; preserve: string[] } + | { action: 'update'; alias: string; package: string; version: string; fromDigest: string; compositionDigest: string; backup: string[]; preserve: string[] } + | { action: 'unchanged'; alias: string; package: string; version: string; compositionDigest: string; addOwner: boolean } + | { action: 'release'; alias: string; remainingOwners: string[] } + | { action: 'remove'; alias: string; package: string } + | { action: 'conflict'; alias: string; package: string; reason: 'local_modified' | 'unowned_directory' | 'contains_link' | 'shared_version'; files: string[]; owners: string[] }; + +export interface InstallPlan { + runtime: string; + owner: string; + skillsDir: string; + actions: PlanAction[]; + conflicts: number; + /** sha256 of the canonical plan: an MCP `apply` may require the plan it was shown. */ + planDigest: string; +} + +export interface PlanInput { + home: string; + runtime: string; + skillsDir: string; + owner: string; + /** The packages the owner needs after this change; empty for uninstall. */ + packages: readonly InstallLockPackage[]; + owners: OwnersFile; + force: boolean; +} + +function planPackage(input: PlanInput, p: InstallLockPackage, receipt: InstallReceipt | null): PlanAction { + const local = inspectLocal(join(input.skillsDir, p.alias), receipt); + // A file, symlink or junction where the skill directory belongs is never replaced, not even with --force. + if (local.unknown.includes('.')) return { action: 'conflict', alias: p.alias, package: p.package, reason: 'unowned_directory', files: ['.'], owners: receipt?.requiredBy ?? [] }; + const action = planChange(input, p, receipt, local); + // A link inside the directory is legitimate user state while nothing is rewritten; a rewrite would have + // to copy or carry it, which the installer never does, so the user resolves it first. + if ((action.action === 'install' || action.action === 'update') && local.links.length > 0) { + return { action: 'conflict', alias: p.alias, package: p.package, reason: 'contains_link', files: local.links, owners: receipt?.requiredBy ?? [] }; + } + return action; +} + +function planChange(input: PlanInput, p: InstallLockPackage, receipt: InstallReceipt | null, local: LocalState): PlanAction { + const base = { alias: p.alias, package: p.package, version: p.version, compositionDigest: p.compositionDigest }; + if (receipt === null) { + if (!local.exists) return { action: 'install', ...base, preserve: [] }; + if (!input.force) return { action: 'conflict', alias: p.alias, package: p.package, reason: 'unowned_directory', files: local.unknown, owners: [] }; + return { action: 'install', ...base, preserve: local.unknown }; + } + const changed = [...local.modified, ...local.missing]; + if (receipt.compositionDigest === p.compositionDigest) { + if (changed.length === 0) return { action: 'unchanged', ...base, addOwner: !receipt.requiredBy.includes(input.owner) }; + if (!input.force) return { action: 'conflict', alias: p.alias, package: p.package, reason: 'local_modified', files: changed, owners: receipt.requiredBy }; + } + const others = receipt.requiredBy.filter((o) => o !== input.owner); + if (receipt.compositionDigest !== p.compositionDigest && others.length > 0) { + return { action: 'conflict', alias: p.alias, package: p.package, reason: 'shared_version', files: [], owners: others }; + } + if (changed.length > 0 && !input.force) return { action: 'conflict', alias: p.alias, package: p.package, reason: 'local_modified', files: changed, owners: receipt.requiredBy }; + return { action: 'update', ...base, fromDigest: receipt.compositionDigest, backup: local.modified, preserve: local.unknown }; +} + +/** Skills the owner held before and no longer needs: released, or removed when no owner is left. */ +function planDropped(input: PlanInput): PlanAction[] { + const previous = input.owners.owners[input.owner]?.lock.packages ?? []; + const keep = new Set(input.packages.map((p) => p.alias)); + const actions: PlanAction[] = []; + for (const p of previous) { + if (keep.has(p.alias)) continue; + const receipt = readReceipt(input.home, input.runtime, p.alias); + if (receipt === null || !receipt.requiredBy.includes(input.owner)) continue; + const remaining = receipt.requiredBy.filter((o) => o !== input.owner); + actions.push(remaining.length > 0 ? { action: 'release', alias: p.alias, remainingOwners: remaining } : { action: 'remove', alias: p.alias, package: receipt.package }); + } + return actions; +} + +export function planInstall(input: PlanInput): InstallPlan { + const actions = [...input.packages.map((p) => planPackage(input, p, readReceipt(input.home, input.runtime, p.alias))), ...planDropped(input)]; + actions.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); + const body = { runtime: input.runtime, owner: input.owner, skillsDir: input.skillsDir, actions }; + return { ...body, conflicts: actions.filter((a) => a.action === 'conflict').length, planDigest: `sha256:${sha256Hex(canonicalJsonBytes(body))}` }; +} + +/** The packages of a lock that a plan installs or updates. */ +export function packagesToFetch(plan: InstallPlan, lock: Pick): InstallLockPackage[] { + const wanted = new Set(plan.actions.filter((a) => a.action === 'install' || a.action === 'update').map((a) => a.alias)); + return lock.packages.filter((p) => wanted.has(p.alias)); +} diff --git a/packages/cli/src/lib/install/registry-client.ts b/packages/cli/src/lib/install/registry-client.ts new file mode 100644 index 0000000..0d64b32 --- /dev/null +++ b/packages/cli/src/lib/install/registry-client.ts @@ -0,0 +1,130 @@ +import { PACKAGE_NAME_PATTERN as PACKAGE } from '@skillx/contracts'; +import { InstallError } from './install-errors.js'; + +/** + * The registry reads the installer needs: signed manifests and file bytes + * from the pinned release read API, kit locks from the personal kit API and + * collection revisions from the collections API. Base URL, credentials and + * `fetch` are injected, so tests drive an in-process server and nothing here + * reads the user's config. The API key is sent only to `baseUrl`. + */ + +export interface RegistryOptions { + baseUrl: string; + apiKey?: string; + fetch?: typeof fetch; +} + +export interface ReleaseSummary { + version: string; + channel: string; + state: string; + releaseDigest: string; +} + +export interface CollectionRevisionView { + id: string; + collectionId: string; + revisionNumber: number; + revisionDigest: string | null; + members: Array<{ packageName: string; version?: string; digest?: string }>; + config: Record; +} + +export interface KitLockResponse { + lock: unknown; + documents: unknown[]; + warnings: string[]; +} + +export interface Registry { + manifest(releaseDigest: string, pkg: string, version: string): Promise; + file(releaseDigest: string, pkg: string, version: string, path: string): Promise; + releases(pkg: string): Promise; + kitLock(kit: string, revisionId?: string): Promise; + collectionRevision(id: string, revision: number | 'latest'): Promise; +} + +const KIT = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9._-]{0,63}$/; +const COLLECTION_ID = /^col_[0-9a-f-]{36}$/; +const REVISION_ID = /^[a-z]+_[0-9a-f-]{36}$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; + +function classify(status: number, what: string): InstallError { + if (status === 404) return new InstallError('not_found', `${what} was not found`, { status }); + if (status === 401 || status === 403) return new InstallError('auth_error', `the registry refused ${what}`, { status }); + return new InstallError('api_error', `the registry answered ${status} for ${what}`, { status }); +} + +const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** Only https, or plain http to this machine: credentials never travel in clear text. */ +export function assertSecureRegistry(baseUrl: string): void { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new InstallError('usage_error', `invalid registry URL ${baseUrl}`); + } + const loopback = LOOPBACK.has(url.hostname) || url.hostname.endsWith('.localhost'); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new InstallError('usage_error', `the registry must use https (plain http only for localhost): ${url.origin}`); + } +} + +export function createRegistry(options: RegistryOptions): Registry { + assertSecureRegistry(options.baseUrl); + const base = options.baseUrl.replace(/\/+$/, ''); + const doFetch = options.fetch ?? fetch; + + async function get(path: string, what: string): Promise { + const headers: Record = { Accept: 'application/json, application/octet-stream' }; + if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`; + let res: Response; + try { + res = await doFetch(`${base}${path}`, { headers, redirect: 'error' }); + } catch (error) { + throw new InstallError('network_error', `could not reach the registry for ${what}: ${error instanceof Error ? error.message : String(error)}`); + } + if (!res.ok) { + void res.body?.cancel().catch(() => undefined); + throw classify(res.status, what); + } + return res; + } + const bytes = async (path: string, what: string) => new Uint8Array(await (await get(path, what)).arrayBuffer()); + const json = async (path: string, what: string) => (await (await get(path, what)).json()) as T; + + const check = (ok: boolean, message: string) => { + if (!ok) throw new InstallError('usage_error', message); + }; + const selector = (pkg: string, version: string) => `?version=${encodeURIComponent(version)}&package=${encodeURIComponent(pkg)}`; + + return { + manifest(releaseDigest, pkg, version) { + check(DIGEST.test(releaseDigest) && PACKAGE.test(pkg), `invalid release reference ${pkg}@${version}`); + return bytes(`/api/v1/releases/${releaseDigest}/manifest${selector(pkg, version)}`, `the manifest of ${pkg}@${version}`); + }, + file(releaseDigest, pkg, version, path) { + check(DIGEST.test(releaseDigest) && PACKAGE.test(pkg), `invalid release reference ${pkg}@${version}`); + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return bytes(`/api/v1/releases/${releaseDigest}/files/${encoded}${selector(pkg, version)}`, `${path} of ${pkg}@${version}`); + }, + async releases(pkg) { + check(PACKAGE.test(pkg), `invalid package name ${pkg}`); + const body = await json<{ releases?: ReleaseSummary[] }>(`/api/v1/packages/${pkg}/releases`, `the releases of ${pkg}`); + return Array.isArray(body.releases) ? body.releases : []; + }, + kitLock(kit, revisionId) { + check(KIT.test(kit), `invalid kit name ${kit}`); + check(revisionId === undefined || REVISION_ID.test(revisionId), `invalid kit revision id ${revisionId}`); + const query = revisionId === undefined ? '' : `?revision=${encodeURIComponent(revisionId)}`; + return json(`/api/v1/kits/${kit}/lock${query}`, `the lock of kit ${kit}`); + }, + async collectionRevision(id, revision) { + check(COLLECTION_ID.test(id), `invalid collection id ${id}`); + const body = await json<{ revision: CollectionRevisionView }>(`/api/v1/collections/${id}/revisions/${revision}`, `collection ${id}`); + return body.revision; + }, + }; +} diff --git a/packages/cli/src/lib/install/release-pins.ts b/packages/cli/src/lib/install/release-pins.ts new file mode 100644 index 0000000..8e96081 --- /dev/null +++ b/packages/cli/src/lib/install/release-pins.ts @@ -0,0 +1,40 @@ +import { compareVersions, parseVersion } from '@skillx/contracts'; +import { integrityError, InstallError } from './install-errors.js'; +import type { Registry } from './registry-client.js'; + +/** + * Pins one package reference to an exact release. An explicit version must + * exist (a yanked one installs with a warning, so a pinned recipe keeps + * working); without a version the newest published release wins, stable + * before prerelease. A pinned digest must equal the release's digest. + */ + +export interface Pin { + version: string; + releaseDigest: string; +} + +/** Highest of a list of versions, preferring releases without a prerelease tag. */ +export function newestVersion(versions: readonly string[]): string | null { + const parsed = versions.map((v) => ({ v, p: parseVersion(v) })).filter((x) => x.p !== null); + const stable = parsed.filter((x) => x.p!.prerelease === null); + const pool = stable.length > 0 ? stable : parsed; + pool.sort((a, b) => compareVersions(b.p!, a.p!)); + return pool[0]?.v ?? null; +} + +export async function resolvePin(registry: Registry, wanted: { name: string; version?: string; digest?: string }, warnings: string[]): Promise { + const releases = await registry.releases(wanted.name); + let version = wanted.version; + if (version === undefined) { + version = newestVersion(releases.filter((r) => r.state === 'published').map((r) => r.version)) ?? undefined; + if (version === undefined) throw new InstallError('not_found', `${wanted.name} has no published release`, { package: wanted.name }); + } + const release = releases.find((r) => r.version === version); + if (release === undefined) throw new InstallError('not_found', `${wanted.name}@${version} was not found`, { package: wanted.name, version }); + if (release.state !== 'published') warnings.push(`${wanted.name}@${version} is ${release.state}`); + if (wanted.digest !== undefined && wanted.digest !== release.releaseDigest) { + throw integrityError(`${wanted.name}@${version} is ${release.releaseDigest}, not the pinned ${wanted.digest}`, { package: wanted.name, version }); + } + return { version, releaseDigest: release.releaseDigest }; +} diff --git a/packages/cli/src/lib/install/remove-owned.ts b/packages/cli/src/lib/install/remove-owned.ts new file mode 100644 index 0000000..129ff62 --- /dev/null +++ b/packages/cli/src/lib/install/remove-owned.ts @@ -0,0 +1,109 @@ +import { lstatSync, readFileSync, readdirSync, rmdirSync, rmSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { sha256Hex } from './fetch-verify.js'; +import { deleteReceipt } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; +import { containedPath, isRealDir, linkFreeParents } from './safe-fs.js'; + +/** + * Removes a skill directory with proof of ownership: a file is deleted only + * when its receipt lists it, its current SHA-256 still equals the receipt's, + * and no directory on its way (the skill directory included) is a symlink or + * junction. Links are never followed: a linked skill directory is left alone + * entirely and reported as unknown (`.`), a linked subdirectory keeps every + * receipt file below it. Changed and unlisted files are kept and reported; + * directories are removed only when they end up empty. The receipt goes last, + * so a crash midway leaves a receipt and a rerun finishes the job. + */ + +export interface RemoveResult { + alias: string; + removed: string[]; + /** Owned files changed locally, or reachable only through a link: kept. */ + keptModified: string[]; + /** Files the receipt never listed (`.` when the skill directory itself is a link): kept. */ + keptUnknown: string[]; +} + +/** SHA-256 of a regular file; '' for anything else; null when nothing is there. */ +function currentSha(path: string): string | null { + try { + const stat = lstatSync(path); + return stat.isFile() ? sha256Hex(readFileSync(path)) : ''; + } catch { + return null; + } +} + +function pruneEmptyDirs(root: string, from: string): void { + let dir = from; + while (dir.startsWith(root) && dir.length >= root.length) { + if (!isRealDir(dir)) return; + try { + if (readdirSync(dir).length > 0) return; + rmdirSync(dir); + } catch { + return; + } + if (dir === root) return; + dir = dirname(dir); + } +} + +/** Every non-directory entry under `root`; links are listed, never entered. */ +function listFiles(root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + const path = join(dir, name); + if (isRealDir(path)) walk(path); + else out.push(relative(root, path).split(sep).join('/')); + } + }; + walk(root); + return out.sort(); +} + +export function removeOwned(home: string, runtime: string, skillsDir: string, receipt: InstallReceipt): RemoveResult { + const root = join(skillsDir, receipt.alias); + const result: RemoveResult = { alias: receipt.alias, removed: [], keptModified: [], keptUnknown: [] }; + let rootPresent = true; + try { + lstatSync(root); + } catch { + rootPresent = false; + } + if (rootPresent && !isRealDir(root)) { + result.keptUnknown = ['.']; + deleteReceipt(home, runtime, receipt.alias); + return result; + } + for (const file of receipt.files) { + const path = containedPath(root, file.path); + if (rootPresent && !linkFreeParents(root, file.path)) { + result.keptModified.push(file.path); + continue; + } + const sha = rootPresent ? currentSha(path) : null; + if (sha === null || sha === file.sha256) { + if (sha !== null) rmSync(path, { force: true }); + result.removed.push(file.path); + if (rootPresent) pruneEmptyDirs(root, dirname(path)); + } else { + result.keptModified.push(file.path); + } + } + const owned = new Set(receipt.files.map((f) => f.path)); + if (rootPresent) { + result.keptUnknown = listFiles(root).filter((p) => !owned.has(p)); + pruneEmptyDirs(root, root); + } + deleteReceipt(home, runtime, receipt.alias); + return result; +} diff --git a/packages/cli/src/lib/install/resolver.ts b/packages/cli/src/lib/install/resolver.ts new file mode 100644 index 0000000..19b209e --- /dev/null +++ b/packages/cli/src/lib/install/resolver.ts @@ -0,0 +1,137 @@ +import { + INSTALL_LOCK_SCHEMA, + INSTALL_RESOLVER_VERSION, + PACKAGE_NAME_PATTERN, + canonicalJsonBytes, + collectionRevisionDigest, + isInstallAlias, + sortLockPackages, +} from '@skillx/contracts'; +import type { InstallLock, InstallLockPackage, PersonalKitDocument, PersonalKitLock, SkillOverlay } from '@skillx/contracts'; +import { sha256Hex } from './fetch-verify.js'; +import { incompatibleError, integrityError, usageError } from './install-errors.js'; +import type { Registry } from './registry-client.js'; +import { resolvePin } from './release-pins.js'; + +/** + * Resolves a source to an exact `skillx.lock/v1`: a kit revision (through + * the personal kit lock), a collection revision, or explicit packages. Every + * package ends pinned by version, release digest and composition digest; + * the overlays a kit entry needs are returned next to the lock (the lock + * holds only their digests). + */ + +export type SourceRef = + | { kind: 'kit'; kit: string; revisionId?: string } + | { kind: 'collection'; id: string; revision: number | 'latest' } + | { kind: 'packages'; packages: Array<{ name: string; version?: string }> }; + +export interface Resolution { + lock: InstallLock; + /** Overlay layers by alias, in lock order; absent for packages installed as published. */ + overlays: Map; + warnings: string[]; +} + +const KIT_REF = /^kit:(@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9._-]{0,63})(?:#([a-z]+_[0-9a-f-]{36}))?$/; +const COLLECTION_REF = /^collection:(col_[0-9a-f-]{36})(?:#(\d{1,9}|latest))?$/; +const PACKAGE_REF = /^(@[^@\s]+\/[^@\s]+)(?:@(\S+))?$/; + +/** `kit:@h/k[#revId]`, `collection:[#n]`, or one or more `@p/s[@version]`. */ +export function parseRefs(refs: readonly string[]): SourceRef { + if (refs.length === 0) throw usageError('name a source: kit:@handle/kit, collection: or @publisher/package'); + const kit = KIT_REF.exec(refs[0]!); + const collection = COLLECTION_REF.exec(refs[0]!); + if ((kit || collection) && refs.length > 1) throw usageError('install one kit or collection at a time'); + if (kit) return { kind: 'kit', kit: kit[1]!, ...(kit[2] ? { revisionId: kit[2] } : {}) }; + if (collection) return { kind: 'collection', id: collection[1]!, revision: collection[2] === undefined || collection[2] === 'latest' ? 'latest' : Number(collection[2]) }; + return { + kind: 'packages', + packages: refs.map((ref) => { + const m = PACKAGE_REF.exec(ref); + if (!m || !PACKAGE_NAME_PATTERN.test(m[1]!)) throw usageError(`'${ref}' is not kit:@h/k, collection: or @publisher/package[@version]`); + return { name: m[1]!, ...(m[2] ? { version: m[2] } : {}) }; + }), + }; +} + +export const overlayDigestOf = (overlay: SkillOverlay) => `sha256:${sha256Hex(canonicalJsonBytes(overlay))}`; + +function checkAliases(packages: readonly InstallLockPackage[]): void { + const seen = new Set(); + for (const p of packages) { + if (!isInstallAlias(p.alias)) throw usageError(`'${p.alias}' (${p.package}) is not a valid skill directory name`); + if (seen.has(p.alias)) throw usageError(`two packages install as '${p.alias}'`, { alias: p.alias }); + seen.add(p.alias); + } +} + +/** Overlays of the kit lock entries, found in the owned documents of the lineage by digest. */ +function kitOverlays(packages: readonly InstallLockPackage[], documents: unknown[]): Map { + const byDigest = new Map(); + for (const doc of documents as Array) { + if (doc.foreign || !Array.isArray(doc.skills)) continue; + for (const entry of doc.skills) if (entry.overlay.ops.length > 0) byDigest.set(overlayDigestOf(entry.overlay), entry.overlay); + } + const overlays = new Map(); + for (const p of packages) { + if (p.overlayDigests.length === 0) continue; + const layers = p.overlayDigests.map((d) => byDigest.get(d)); + if (layers.some((l) => l === undefined)) { + throw integrityError(`the overlay of ${p.alias} is not readable (it belongs to a kit you do not own)`, { alias: p.alias }); + } + overlays.set(p.alias, layers as SkillOverlay[]); + } + return overlays; +} + +export function checkRuntimes(runtimes: Record, runtime: string): void { + if (Object.keys(runtimes).length > 0 && !(runtime in runtimes)) { + throw incompatibleError(`this source targets ${Object.keys(runtimes).join(', ')}, not ${runtime}`, { runtime, runtimes }); + } +} + +async function resolveKit(registry: Registry, ref: { kit: string; revisionId?: string }): Promise { + const res = await registry.kitLock(ref.kit, ref.revisionId); + const kl = res.lock as PersonalKitLock | null; + if (kl === null) throw incompatibleError(`kit ${ref.kit} has no installable revision`, { warnings: res.warnings }); + const packages = sortLockPackages( + kl.entries.map((e) => ({ alias: e.alias, package: e.package, version: e.version, releaseDigest: e.releaseDigest, compositionDigest: e.compositionDigest, overlayDigests: e.overlayDigests })), + ); + checkAliases(packages); + const lock: InstallLock = { + schema: INSTALL_LOCK_SCHEMA, + resolver: INSTALL_RESOLVER_VERSION, + source: { kind: 'kit', kit: kl.kit, revisionId: kl.revisionId, revisionNumber: kl.revisionNumber, revisionDigest: kl.revisionDigest }, + runtimes: kl.runtimes, + packages, + }; + return { lock, overlays: kitOverlays(packages, res.documents), warnings: res.warnings }; +} + +async function resolvePackages(registry: Registry, wanted: Array<{ name: string; version?: string; digest?: string }>, warnings: string[]): Promise { + const packages: InstallLockPackage[] = []; + for (const w of wanted) { + const pin = await resolvePin(registry, w, warnings); + packages.push({ alias: w.name.slice(w.name.indexOf('/') + 1), package: w.name, version: pin.version, releaseDigest: pin.releaseDigest, compositionDigest: pin.releaseDigest, overlayDigests: [] }); + } + const sorted = sortLockPackages(packages); + checkAliases(sorted); + return sorted; +} + +export async function resolveSource(registry: Registry, ref: SourceRef): Promise { + const warnings: string[] = []; + const base = { schema: INSTALL_LOCK_SCHEMA, resolver: INSTALL_RESOLVER_VERSION, runtimes: {} } as const; + if (ref.kind === 'kit') return resolveKit(registry, ref); + if (ref.kind === 'packages') { + return { lock: { ...base, source: { kind: 'packages' }, packages: await resolvePackages(registry, ref.packages, warnings) }, overlays: new Map(), warnings }; + } + const rev = await registry.collectionRevision(ref.id, ref.revision); + const digest = await collectionRevisionDigest({ members: rev.members as never, config: rev.config as never }); + if (rev.revisionDigest !== null && rev.revisionDigest !== digest) throw integrityError(`collection ${ref.id} revision ${rev.revisionNumber} does not match its digest`); + const wanted = rev.members.map((m) => ({ name: m.packageName, ...(m.version ? { version: m.version } : {}), ...(m.digest ? { digest: m.digest } : {}) })); + const source = { kind: 'collection' as const, collectionId: rev.collectionId, revisionNumber: rev.revisionNumber, revisionDigest: digest }; + return { lock: { ...base, source, packages: await resolvePackages(registry, wanted, warnings) }, overlays: new Map(), warnings }; +} + diff --git a/packages/cli/src/lib/install/safe-fs.ts b/packages/cli/src/lib/install/safe-fs.ts new file mode 100644 index 0000000..0151a09 --- /dev/null +++ b/packages/cli/src/lib/install/safe-fs.ts @@ -0,0 +1,72 @@ +import { lstatSync, renameSync } from 'node:fs'; +import { join } from 'node:path'; +import { integrityError } from './install-errors.js'; + +/** + * Filesystem guards of the installer. Paths inside a skill come from signed + * manifests and overlays, but every write and delete is still confined to + * its root: a relative path must be plain POSIX segments, and no directory on + * the way may be a symlink or a Windows junction (both report + * `isSymbolicLink()` from `lstat`), so nothing is ever written or deleted + * outside the skills directory through a link. + */ + +const BAD_SEGMENT = /^\.{1,2}$|[\\:\0]/; + +/** `root` joined with a manifest path, refusing absolute paths, `..`, backslashes and drive letters. */ +export function containedPath(root: string, rel: string): string { + const segments = rel.split('/'); + if (rel === '' || rel.startsWith('/') || segments.some((s) => s === '' || BAD_SEGMENT.test(s))) { + throw integrityError(`refusing the unsafe path '${rel}'`, { path: rel }); + } + return join(root, ...segments); +} + +/** True when `path` exists as a real directory (not a symlink or junction). */ +export function isRealDir(path: string): boolean { + try { + const stat = lstatSync(path); + return stat.isDirectory() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +/** True when `root` and every directory between it and `rel`'s parent are real directories. */ +export function linkFreeParents(root: string, rel: string): boolean { + if (!isRealDir(root)) return false; + const segments = rel.split('/'); + let dir = root; + for (const segment of segments.slice(0, -1)) { + dir = join(dir, segment); + if (!isRealDir(dir)) return false; + } + return true; +} + +export interface RenameHooks { + /** Test hook: replaces `renameSync`, to simulate a locked file. */ + rename?(from: string, to: string): void; +} + +const RETRYABLE = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY']); +const BACKOFF_MS = [25, 50, 100, 200, 400]; +const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + +/** + * `rename` that retries while Windows holds the path (an editor, indexer or + * antivirus scan answers EPERM/EBUSY for a moment). The last error is thrown. + */ +export function renameWithRetry(from: string, to: string, hooks?: RenameHooks): void { + const rename = hooks?.rename ?? renameSync; + for (let attempt = 0; ; attempt += 1) { + try { + rename(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (!RETRYABLE.has(code) || attempt >= BACKOFF_MS.length) throw error; + sleep(BACKOFF_MS[attempt]!); + } + } +} diff --git a/packages/cli/src/lib/install/sync-locked.ts b/packages/cli/src/lib/install/sync-locked.ts new file mode 100644 index 0000000..2bd3d48 --- /dev/null +++ b/packages/cli/src/lib/install/sync-locked.ts @@ -0,0 +1,87 @@ +import { collectionRevisionDigest, lockDigest, validateInstallLock } from '@skillx/contracts'; +import type { InstallLock } from '@skillx/contracts'; +import type { InstallContext } from './install-context.js'; +import { conflictError, usageError } from './install-errors.js'; +import { applyPlanned, planResolution } from './install-operations.js'; +import type { ApplyOutcome, SourcePlan } from './install-operations.js'; +import { resolveSource } from './resolver.js'; +import type { Resolution } from './resolver.js'; +import type { CollectionRevisionView } from './registry-client.js'; + +/** + * `skillx sync --locked`: restore exactly what a lock pins, on any machine. + * The lock is authoritative and nothing is re-resolved to something newer: + * + * - a kit lock is compared with the server's lock of the same revision; any + * difference (another digest, alias or overlay) is drift and is refused; + * - a collection lock must name a revision whose content still hashes to the + * locked revision digest, and must lock exactly that revision's members + * (at their pinned version or digest when the member pins one); + * - every package is then fetched by its pinned digests and verified, and + * local edits to installed files are conflicts, not silently overwritten. + */ + +export function parseLock(input: unknown): InstallLock { + const verdict = validateInstallLock(input); + if (!verdict.valid) { + throw usageError('the lock is not a valid skillx.lock/v1 document', { issues: verdict.issues.map((i) => ({ path: i.path, code: i.code })) }); + } + return input as InstallLock; +} + +/** + * Where a collection lock disagrees with the members of its revision: every + * member must be locked exactly once and nothing else, a pinned version or + * digest must be the locked one. A floating member may be locked at any + * version (the lock is what pins it). + */ +export function memberDrift(members: CollectionRevisionView['members'], lock: InstallLock): string[] { + const drift: string[] = []; + const locked = new Map(lock.packages.map((p) => [p.package, p])); + for (const m of members) { + const p = locked.get(m.packageName); + if (p === undefined) drift.push(`${m.packageName}: member not locked`); + else if (m.version !== undefined && m.version !== p.version) drift.push(`${m.packageName}: member pins ${m.version}, lock has ${p.version}`); + else if (m.digest !== undefined && m.digest !== p.releaseDigest) drift.push(`${m.packageName}: member pins another release digest`); + locked.delete(m.packageName); + } + for (const name of locked.keys()) drift.push(`${name}: locked but not a member`); + return drift; +} + +async function lockedResolution(ctx: InstallContext, lock: InstallLock): Promise { + const source = lock.source; + if (source.kind === 'kit') { + const remote = await resolveSource(ctx.registry, { kind: 'kit', kit: source.kit, revisionId: source.revisionId }); + const [want, have] = [await lockDigest(lock), await lockDigest(remote.lock)]; + if (want !== have) throw conflictError(`the lock no longer matches kit ${source.kit} revision ${source.revisionNumber}; refusing to drift`, { expected: want, actual: have }); + return remote; + } + if (source.kind === 'collection') { + const rev = await ctx.registry.collectionRevision(source.collectionId, source.revisionNumber); + const digest = await collectionRevisionDigest({ members: rev.members as never, config: rev.config as never }); + if (digest !== source.revisionDigest) { + throw conflictError(`collection ${source.collectionId} revision ${source.revisionNumber} changed; refusing to drift`, { expected: source.revisionDigest, actual: digest }); + } + const drift = memberDrift(rev.members, lock); + if (drift.length > 0) throw conflictError(`the lock does not pin the members of collection ${source.collectionId} revision ${source.revisionNumber}; refusing to drift`, { drift }); + } + return { lock, overlays: new Map(), warnings: [] }; +} + +function refOf(lock: InstallLock): { sourceRef: string; refFor: (pkg: string) => string } { + const s = lock.source; + const byName = new Map(lock.packages.map((p) => [p.package, `${p.package}@${p.version}`])); + const sourceRef = s.kind === 'kit' ? `kit:${s.kit}#${s.revisionId}` : s.kind === 'collection' ? `collection:${s.collectionId}#${s.revisionNumber}` : ''; + return { sourceRef, refFor: (pkg) => byName.get(pkg) ?? pkg }; +} + +export async function planLocked(ctx: InstallContext, lock: InstallLock): Promise { + const { sourceRef, refFor } = refOf(lock); + return planResolution(ctx, await lockedResolution(ctx, lock), refFor, sourceRef); +} + +export function syncLocked(ctx: InstallContext, input: unknown, expectDigest?: string): Promise { + const lock = parseLock(input); + return applyPlanned(ctx, () => planLocked(ctx, lock), expectDigest); +} diff --git a/packages/cli/src/lib/install/tree-proof.ts b/packages/cli/src/lib/install/tree-proof.ts new file mode 100644 index 0000000..eceff0e --- /dev/null +++ b/packages/cli/src/lib/install/tree-proof.ts @@ -0,0 +1,74 @@ +import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { scanDir } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; +import { containedPath } from './safe-fs.js'; + +/** + * Proof that replacing a skill directory loses nothing. Every entry of the + * old directory must be accounted for: an owned file still matching the + * previous receipt, a file carried into the new tree with the same content, + * or a file already copied to the backup area. Anything else (a file the + * user created or edited after the plan was made) is unproven. + */ + +export interface ProofInput { + /** The directory about to be (or just) replaced. */ + dir: string; + previous: InstallReceipt | null; + /** Where preserved files were carried: the stage before the swap, the target after. */ + carried: string; + /** The backup root of this change (may not exist). */ + saved: string; +} + +export function unprovenFiles(input: ProofInput): string[] { + const owned = new Map((input.previous?.files ?? []).map((f) => [f.path, f.sha256])); + const carried = existsSync(input.carried) ? new Map(scanDir(input.carried).map((f) => [f.path, f.sha256])) : new Map(); + const saved = existsSync(input.saved) ? new Map(scanDir(input.saved).map((f) => [f.path, f.sha256])) : new Map(); + return scanDir(input.dir) + .filter((f) => !((f.sha256 !== '' && owned.get(f.path) === f.sha256) || carried.get(f.path) === f.sha256 || saved.get(f.path) === f.sha256)) + .map((f) => f.path); +} + +/** Removes a link (symlink or junction) itself, never what it points to. */ +function unlinkEntry(path: string): void { + try { + unlinkSync(path); + } catch { + rmdirSync(path); // a Windows junction or directory symlink + } +} + +/** + * Drops the old directory after a swap. Unproven files are first copied to + * the backup root (next to any earlier copy of the same path, as + * `.displaced`), so a file that appeared after the last check is never + * deleted. A link is never followed: its target path is saved as + * `.link` and the link itself is removed. Returns the paths it saved. + */ +export function retireOld(input: ProofInput): string[] { + if (!existsSync(input.dir)) return []; + const displaced = unprovenFiles(input); + for (const path of displaced) { + const from = containedPath(input.dir, path); + const isLink = !lstatSync(from).isFile(); + let dest = containedPath(input.saved, path) + (isLink ? '.link' : ''); + if (existsSync(dest)) dest = `${dest}.displaced`; + mkdirSync(dirname(dest), { recursive: true }); + if (!isLink) { + cpSync(from, dest); + continue; + } + let pointsTo = ''; + try { + pointsTo = readlinkSync(from); + } catch { + // another special entry: there is no target to record + } + writeFileSync(dest, `${pointsTo}\n`); + unlinkEntry(from); + } + rmSync(input.dir, { recursive: true, force: true }); + return displaced; +} diff --git a/packages/cli/src/lib/install/trusted-keys.ts b/packages/cli/src/lib/install/trusted-keys.ts new file mode 100644 index 0000000..2a89721 --- /dev/null +++ b/packages/cli/src/lib/install/trusted-keys.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Env } from './adapters.js'; +import { usageError } from './install-errors.js'; + +/** + * Release signing keys the installer trusts. A manifest signed by any other + * key id fails verification, so with no trusted key nothing installs (fail + * closed). Sources, merged in this order: + * + * 1. `EMBEDDED_KEYS`: keys shipped with the CLI. Empty until the registry + * operator publishes the production public key. + * 2. `/trusted-keys.json`: `{"keys": {"": ""}}`. + * 3. `SKILLX_TRUSTED_KEYS`: `=[,=...]`. + * + * Public keys are not secrets; they are still never printed in full. + */ + +const EMBEDDED_KEYS: Record = {}; + +const KEY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function decodeKey(keyId: string, encoded: string, source: string): Uint8Array { + if (!KEY_ID.test(keyId)) throw usageError(`invalid trusted key id in ${source}`); + const bytes = new Uint8Array(Buffer.from(encoded.trim(), 'base64')); + if (bytes.length !== 32) throw usageError(`trusted key ${keyId} in ${source} is not a 32-byte Ed25519 public key`); + return bytes; +} + +function fromFile(path: string): Array<[string, Uint8Array]> { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw usageError(`${path} is not JSON`); + } + const keys = (parsed as { keys?: unknown }).keys; + if (typeof keys !== 'object' || keys === null || Array.isArray(keys)) throw usageError(`${path} must be {"keys": {"": ""}}`); + return Object.entries(keys).map(([id, value]) => { + if (typeof value !== 'string') throw usageError(`trusted key ${id} in ${path} must be a base64 string`); + return [id, decodeKey(id, value, path)]; + }); +} + +function fromEnv(value: string | undefined): Array<[string, Uint8Array]> { + if (!value) return []; + return value + .split(',') + .filter((part) => part.trim() !== '') + .map((part) => { + const at = part.indexOf('='); + if (at <= 0) throw usageError('SKILLX_TRUSTED_KEYS must be =[,...]'); + const id = part.slice(0, at).trim(); + return [id, decodeKey(id, part.slice(at + 1), 'SKILLX_TRUSTED_KEYS')]; + }); +} + +export function loadTrustedKeys(home: string, env: Env = process.env): Map { + const keys = new Map(); + for (const [id, value] of Object.entries(EMBEDDED_KEYS)) keys.set(id, decodeKey(id, value, 'the CLI')); + for (const [id, value] of fromFile(join(home, 'trusted-keys.json'))) keys.set(id, value); + for (const [id, value] of fromEnv(env.SKILLX_TRUSTED_KEYS)) keys.set(id, value); + return keys; +} diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index ea7f6f0..f5181ff 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -25,6 +25,8 @@ export const EXIT_CODES = { auth: 5, /** The API could not be reached. */ network: 6, + /** Local files block an install change (modified, unowned or shared). */ + conflict: 8, } as const; export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; @@ -36,6 +38,8 @@ export type CliErrorCode = | 'auth_error' | 'network_error' | 'api_error' + | 'integrity_error' + | 'conflict' | 'unexpected_error'; const EXIT_BY_CODE: Record = { @@ -45,6 +49,8 @@ const EXIT_BY_CODE: Record = { auth_error: EXIT_CODES.auth, network_error: EXIT_CODES.network, api_error: EXIT_CODES.error, + integrity_error: EXIT_CODES.error, + conflict: EXIT_CODES.conflict, unexpected_error: EXIT_CODES.error, }; diff --git a/packages/contracts/src/collection-revision.ts b/packages/contracts/src/collection-revision.ts new file mode 100644 index 0000000..e496784 --- /dev/null +++ b/packages/contracts/src/collection-revision.ts @@ -0,0 +1,70 @@ +/** + * Collection revision content: the member list and the non-secret config of + * one immutable revision, validated together, and its digest. The digest is + * the canonical JSON of `{config, members}` with members sorted by package, + * so equal content always gets an equal digest whatever the input order. + */ +import { canonicalJsonBytes } from "./canonical-json"; +import { validateCollectionMember } from "./collections"; +import type { CollectionConfig, CollectionMember } from "./collections"; +import { sha256Digest } from "./release-signature"; +import { findSyncLeaks } from "./sync-leaks"; +import { isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; + +export const COLLECTION_LIMITS = { maxMembers: 256, maxConfigKeys: 64, maxConfigValueLength: 1024 } as const; +const CONFIG_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; + +export interface CollectionContent { + members: CollectionMember[]; + config: CollectionConfig; +} + +/** Members (unique by package) and config (flat scalars, no secrets, no absolute paths). */ +export function validateCollectionContent(input: { members?: unknown; config?: unknown }): ValidationResult { + const issues: ValidationIssue[] = []; + const members = input.members ?? []; + if (!Array.isArray(members)) { + issues.push(issue("members", "type", "members must be an array")); + } else { + if (members.length > COLLECTION_LIMITS.maxMembers) issues.push(issue("members", "limit", `at most ${COLLECTION_LIMITS.maxMembers} members`)); + const seen = new Set(); + members.forEach((member, i) => { + for (const found of validateCollectionMember(member).issues) issues.push(issue(`members[${i}]${found.path === "" ? "" : `.${found.path}`}`, found.code, found.message)); + if (isPlainObject(member)) { + for (const key of Object.keys(member)) { + if (!["packageName", "version", "digest", "optional"].includes(key)) issues.push(issue(`members[${i}].${key}`, "unknown_field", "is not a member field")); + } + if (member.digest !== undefined && member.version === undefined) issues.push(issue(`members[${i}].digest`, "required", "a digest pin needs its version")); + const name = String(member.packageName); + if (seen.has(name)) issues.push(issue(`members[${i}].packageName`, "duplicate", `${name} is listed twice`)); + seen.add(name); + } + }); + } + const config = input.config ?? {}; + if (!isPlainObject(config)) { + issues.push(issue("config", "type", "config must be an object")); + } else { + const entries = Object.entries(config); + if (entries.length > COLLECTION_LIMITS.maxConfigKeys) issues.push(issue("config", "limit", `at most ${COLLECTION_LIMITS.maxConfigKeys} keys`)); + for (const [key, value] of entries) { + if (!CONFIG_KEY.test(key)) issues.push(issue(`config.${key}`, "pattern", "config keys are short identifiers")); + const scalar = typeof value === "boolean" || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) || typeof value === "string"; + if (!scalar) issues.push(issue(`config.${key}`, "type", "config values are strings, booleans or non-negative integers")); + if (typeof value === "string" && value.length > COLLECTION_LIMITS.maxConfigValueLength) issues.push(issue(`config.${key}`, "limit", "config value too long")); + } + } + for (const leak of findSyncLeaks({ members, config })) issues.push(issue(leak.path, `sync.${leak.rule}`, "collections must not carry secrets or absolute local paths")); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** Members sorted by package name; the stored and digested order. */ +export function sortCollectionMembers(members: readonly CollectionMember[]): CollectionMember[] { + return [...members].sort((a, b) => (a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0)); +} + +/** `sha256:` of the canonical `{config, members}` of a revision. */ +export function collectionRevisionDigest(content: CollectionContent): Promise { + return sha256Digest(canonicalJsonBytes({ config: content.config, members: sortCollectionMembers(content.members) })); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f854a73..8fea069 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,3 +23,6 @@ export * from "./dto"; export * from "./payload-access"; export * from "./fixtures"; export * from "./personal-kit"; +export * from "./sync-leaks"; +export * from "./install-lock"; +export * from "./collection-revision"; diff --git a/packages/contracts/src/install-lock.test.ts b/packages/contracts/src/install-lock.test.ts new file mode 100644 index 0000000..b733003 --- /dev/null +++ b/packages/contracts/src/install-lock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { INSTALL_LOCK_SCHEMA, INSTALL_RESOLVER_VERSION, lockDigest, lockOwnerKey, validateInstallLock } from "./install-lock"; +import type { InstallLock } from "./install-lock"; + +const D = (c: string) => `sha256:${c.repeat(64)}`; +const lock = (): InstallLock => ({ + schema: INSTALL_LOCK_SCHEMA, + resolver: INSTALL_RESOLVER_VERSION, + source: { kind: "kit", kit: "@alpha/my-kit", revisionId: "rev_00000000-0000-4000-8000-000000000001", revisionNumber: 2, revisionDigest: D("a") }, + runtimes: { "claude-code": ">=1.0.0" }, + packages: [ + { alias: "demo", package: "@alpha/demo", version: "1.0.0", releaseDigest: D("b"), compositionDigest: D("b"), overlayDigests: [] }, + { alias: "zed", package: "@alpha/zed", version: "2.0.0", releaseDigest: D("c"), compositionDigest: D("d"), overlayDigests: [D("e")] }, + ], +}); + +describe("skillx.lock/v1", () => { + it("validates a well-formed lock and digests it deterministically", async () => { + expect(validateInstallLock(lock())).toEqual({ valid: true, issues: [] }); + const { packages, ...rest } = lock(); + const reordered = JSON.parse(JSON.stringify({ packages, ...rest })); + expect(await lockDigest(reordered)).toBe(await lockDigest(lock())); + expect(lockOwnerKey(lock().source, lock().packages)).toBe("kit:@alpha/my-kit"); + }); + + it("rejects unsorted or duplicate aliases, unknown fields and composed digests without overlay", () => { + const bad = lock(); + bad.packages.reverse(); + (bad as unknown as Record).extra = 1; + bad.packages[1]!.compositionDigest = D("f"); + const codes = validateInstallLock(bad).issues.map((i) => `${i.path}:${i.code}`); + expect(codes).toEqual(expect.arrayContaining(["extra:unknown_field", "packages[1].alias:order", "packages[1].compositionDigest:mismatch"])); + }); + + it("refuses a lock that would sync a secret or an absolute path", () => { + const leaky = lock() as unknown as { runtimes: Record }; + leaky.runtimes = { "claude-code": "/home/alice/bin/claude" }; + const issues = validateInstallLock(leaky).issues; + expect(issues).toContainEqual(expect.objectContaining({ path: "runtimes.claude-code", code: "sync.absolute-path" })); + }); + + it("rejects an unknown source kind and a malformed version", () => { + const bad = lock() as unknown as { source: unknown; packages: Array<{ version: string }> }; + bad.source = { kind: "git" }; + bad.packages[0]!.version = "latest"; + const codes = validateInstallLock(bad).issues.map((i) => i.path); + expect(codes).toEqual(expect.arrayContaining(["source.kind", "packages[0].version"])); + }); +}); diff --git a/packages/contracts/src/install-lock.ts b/packages/contracts/src/install-lock.ts new file mode 100644 index 0000000..51bfb67 --- /dev/null +++ b/packages/contracts/src/install-lock.ts @@ -0,0 +1,150 @@ +/** + * `skillx.lock/v1`: the lockfile (`skillx.lock.json`) of the generic SkillX + * installer. It names what to install, never where: every package is pinned + * by exact version, release tree digest and the digest of the composed tree + * that lands on disk (equal to the release digest when there is no overlay). + * It carries no clock, no machine path and no secret, so the same source + * resolves to the same bytes on every machine and the file is safe to sync. + * + * `lockDigest` hashes the canonical JSON of the whole document; two machines + * agree on a lock exactly when their digests agree. + */ +import { canonicalJsonBytes } from "./canonical-json"; +import { PACKAGE_NAME_PATTERN, SEMVER_PATTERN } from "./package-manifest"; +import { findSyncLeaks } from "./sync-leaks"; +import { isNonEmptyString, isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; +import { RELEASE_DIGEST_PATTERN } from "./verification-evidence"; +import { sha256Digest } from "./release-signature"; + +export const INSTALL_LOCK_SCHEMA = "skillx.lock/v1"; +export const INSTALL_RESOLVER_VERSION = "skillx.install-resolver/1"; +export const INSTALL_LOCK_FILENAME = "skillx.lock.json"; +/** Installed directory name: a lowercase skill alias that never ends in a dot. */ +export const INSTALL_ALIAS_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9_-])?$/; +/** Device names Windows refuses as a file or directory name, with or without an extension. */ +const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\..*)?$/; + +/** True when `alias` is safe as a skills directory name on every platform. */ +export function isInstallAlias(alias: string): boolean { + return INSTALL_ALIAS_PATTERN.test(alias) && !WINDOWS_RESERVED.test(alias); +} +const KIT_PATTERN = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9._-]{0,63}$/; +const ID_PATTERN = /^[a-z]+_[0-9a-f-]{36}$/; +const RUNTIME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/; +export const INSTALL_LOCK_LIMITS = { maxPackages: 256 } as const; + +export type InstallLockSource = + | { kind: "kit"; kit: string; revisionId: string; revisionNumber: number; revisionDigest: string } + | { kind: "collection"; collectionId: string; revisionNumber: number; revisionDigest: string } + | { kind: "packages" }; + +export interface InstallLockPackage { + alias: string; + package: string; + version: string; + releaseDigest: string; + /** Digest of the tree that is installed: the release tree with every overlay applied. */ + compositionDigest: string; + /** One digest per overlay layer; empty when the release is installed as published. */ + overlayDigests: string[]; +} + +export interface InstallLock { + schema: typeof INSTALL_LOCK_SCHEMA; + resolver: typeof INSTALL_RESOLVER_VERSION; + source: InstallLockSource; + /** Runtime constraints declared by the source (kit `runtimes`); empty means unconstrained. */ + runtimes: Record; + packages: InstallLockPackage[]; +} + +/** + * The owner key a source installs under, e.g. `kit:@alpha/my-kit`. Stable + * across revisions. Explicit packages are owned one per package + * (`packages:@alpha/demo`): callers split a packages lock and pass each + * package on its own. + */ +export function lockOwnerKey(source: InstallLockSource, packages: readonly InstallLockPackage[]): string { + if (source.kind === "kit") return `kit:${source.kit}`; + if (source.kind === "collection") return `collection:${source.collectionId}`; + return `packages:${packages.map((p) => p.package).join(",")}`; +} + +/** `sha256:` of the canonical JSON of the lock. */ +export function lockDigest(lock: InstallLock): Promise { + return sha256Digest(canonicalJsonBytes(lock)); +} + +/** Packages sorted by alias (UTF-16 order is fine: aliases are ASCII). */ +export function sortLockPackages(packages: readonly InstallLockPackage[]): InstallLockPackage[] { + return [...packages].sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); +} + +function checkKeys(value: Record, allowed: readonly string[], path: string, issues: ValidationIssue[]): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) issues.push(issue(path === "" ? key : `${path}.${key}`, "unknown_field", "is not a skillx.lock/v1 field")); + } +} + +function checkSource(source: unknown, issues: ValidationIssue[]): void { + if (!isPlainObject(source)) return void issues.push(issue("source", "type", "must be an object")); + const revision = (keys: string[]) => { + checkKeys(source, ["kind", ...keys], "source", issues); + if (!Number.isSafeInteger(source.revisionNumber) || (source.revisionNumber as number) < 1) issues.push(issue("source.revisionNumber", "type", "must be a positive integer")); + if (!RELEASE_DIGEST_PATTERN.test(String(source.revisionDigest))) issues.push(issue("source.revisionDigest", "pattern", "must be sha256:")); + }; + if (source.kind === "kit") { + revision(["kit", "revisionId", "revisionNumber", "revisionDigest"]); + if (!KIT_PATTERN.test(String(source.kit))) issues.push(issue("source.kit", "pattern", "must be @handle/name")); + if (!ID_PATTERN.test(String(source.revisionId))) issues.push(issue("source.revisionId", "pattern", "must be a revision id")); + } else if (source.kind === "collection") { + revision(["collectionId", "revisionNumber", "revisionDigest"]); + if (!ID_PATTERN.test(String(source.collectionId))) issues.push(issue("source.collectionId", "pattern", "must be a collection id")); + } else if (source.kind === "packages") { + checkKeys(source, ["kind"], "source", issues); + } else { + issues.push(issue("source.kind", "unsupported_value", "must be kit, collection or packages")); + } +} + +function checkPackage(pkg: unknown, at: string, issues: ValidationIssue[]): void { + if (!isPlainObject(pkg)) return void issues.push(issue(at, "type", "must be an object")); + checkKeys(pkg, ["alias", "package", "version", "releaseDigest", "compositionDigest", "overlayDigests"], at, issues); + if (!isInstallAlias(String(pkg.alias))) issues.push(issue(`${at}.alias`, "pattern", "must be a lowercase directory alias, not ending in '.' nor a Windows device name")); + if (!isNonEmptyString(pkg.package) || !PACKAGE_NAME_PATTERN.test(pkg.package)) issues.push(issue(`${at}.package`, "pattern", "must be @publisher/slug")); + if (!isNonEmptyString(pkg.version) || !SEMVER_PATTERN.test(pkg.version)) issues.push(issue(`${at}.version`, "pattern", "must be an exact semantic version")); + for (const field of ["releaseDigest", "compositionDigest"] as const) { + if (!RELEASE_DIGEST_PATTERN.test(String(pkg[field]))) issues.push(issue(`${at}.${field}`, "pattern", "must be sha256:")); + } + if (!Array.isArray(pkg.overlayDigests) || pkg.overlayDigests.some((d) => !RELEASE_DIGEST_PATTERN.test(String(d)))) { + issues.push(issue(`${at}.overlayDigests`, "type", "must be an array of sha256:")); + } else if (pkg.overlayDigests.length === 0 && pkg.compositionDigest !== pkg.releaseDigest) { + issues.push(issue(`${at}.compositionDigest`, "mismatch", "must equal releaseDigest when there is no overlay")); + } +} + +/** Structural validation, plus the rule that a lock never carries a secret or an absolute path. */ +export function validateInstallLock(input: unknown): ValidationResult { + if (!isPlainObject(input)) return validationFail([issue("", "type", "lock must be an object")]); + const issues: ValidationIssue[] = []; + checkKeys(input, ["schema", "resolver", "source", "runtimes", "packages"], "", issues); + if (input.schema !== INSTALL_LOCK_SCHEMA) issues.push(issue("schema", "unsupported_value", `must equal '${INSTALL_LOCK_SCHEMA}'`)); + if (input.resolver !== INSTALL_RESOLVER_VERSION) issues.push(issue("resolver", "unsupported_value", `must equal '${INSTALL_RESOLVER_VERSION}'`)); + checkSource(input.source, issues); + if (!isPlainObject(input.runtimes) || Object.entries(input.runtimes).some(([k, v]) => !RUNTIME_PATTERN.test(k) || typeof v !== "string")) { + issues.push(issue("runtimes", "type", "must map runtime ids to version ranges")); + } + if (!Array.isArray(input.packages)) { + issues.push(issue("packages", "type", "must be an array")); + } else { + if (input.packages.length > INSTALL_LOCK_LIMITS.maxPackages) issues.push(issue("packages", "limit", `at most ${INSTALL_LOCK_LIMITS.maxPackages} packages`)); + input.packages.forEach((pkg, i) => checkPackage(pkg, `packages[${i}]`, issues)); + const aliases = input.packages.map((p) => (isPlainObject(p) ? String(p.alias) : "")); + aliases.forEach((alias, i) => { + if (i > 0 && aliases[i - 1]! >= alias) issues.push(issue(`packages[${i}].alias`, "order", "packages must be sorted by alias without duplicates")); + }); + } + for (const leak of findSyncLeaks(input)) issues.push(issue(leak.path, `sync.${leak.rule}`, "a lock must not carry secrets or absolute local paths")); + return issues.length === 0 ? validationOk() : validationFail(issues); +} diff --git a/packages/contracts/src/sync-leaks.test.ts b/packages/contracts/src/sync-leaks.test.ts new file mode 100644 index 0000000..4d484d1 --- /dev/null +++ b/packages/contracts/src/sync-leaks.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { findSyncLeaks, hasAbsolutePath, hasSecretToken } from "./sync-leaks"; +import { collectionRevisionDigest, validateCollectionContent } from "./collection-revision"; + +describe("sync-leak scanner", () => { + it.each([ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "Authorization: Bearer abcdefghijklmnop1234", + "https://user:hunter22@example.com/repo.git", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", + "ghp_0123456789abcdefghijklmnopqrstuvwxyz", + "sk-ant-api03-abcdefghijklmnopqrstuvwx", + "AKIAABCDEFGHIJKLMNOP", + "sx_abcdefghijklmnopqrstuv", + ])("flags the credential %#", (text) => { + expect(hasSecretToken(text)).toBe(true); + }); + + it.each(["/home/alice/.claude/skills", "/Users/bob/x", "C:\\Users\\carol", "~/notes", "\\\\server\\share", "file:///etc/passwd", "path=/home/x/y"])( + "flags the absolute path %s", + (text) => expect(hasAbsolutePath(text)).toBe(true), + ); + + it.each(["scripts/run.sh", "@alpha/demo", "sha256:" + "a".repeat(64), "https://github.com/a/b", "claude-code", "1.2.3"])("passes %s", (text) => { + expect(hasSecretToken(text) || hasAbsolutePath(text)).toBe(false); + }); + + it("reports paths and rules, never the matched text", () => { + const leaks = findSyncLeaks({ config: { apiKey: "abcdef123456", home: "/home/me", ok: "fine" }, list: ["x", "ghp_0123456789abcdefghijklmnopqrstuvwxyz"] }); + expect(leaks).toEqual([ + { path: "config.apiKey", rule: "secret-key" }, + { path: "config.home", rule: "absolute-path" }, + { path: "list[1]", rule: "secret-token" }, + ]); + expect(JSON.stringify(leaks)).not.toContain("abcdef123456"); + }); +}); + +describe("collection revision content", () => { + const member = { packageName: "@alpha/demo", version: "1.0.0" }; + + it("accepts members and scalar config, and digests independent of member order", async () => { + const a = { members: [member, { packageName: "@alpha/zed" }], config: { scope: "user" } }; + const b = { members: [{ packageName: "@alpha/zed" }, member], config: { scope: "user" } }; + expect(validateCollectionContent(a).valid).toBe(true); + expect(await collectionRevisionDigest(a)).toBe(await collectionRevisionDigest(b)); + }); + + it("refuses duplicates, digest pins without version, nested config, secrets and paths", () => { + const result = validateCollectionContent({ + members: [member, member, { packageName: "@alpha/x", digest: "sha256:" + "b".repeat(64) }], + config: { nested: { a: 1 }, token: "abcdefgh1234", dir: "C:\\Users\\me" } as never, + }); + const codes = result.issues.map((i) => `${i.path}:${i.code}`); + expect(codes).toEqual( + expect.arrayContaining(["members[1].packageName:duplicate", "members[2].digest:required", "config.nested:type", "config.token:sync.secret-key", "config.dir:sync.absolute-path"]), + ); + }); +}); diff --git a/packages/contracts/src/sync-leaks.ts b/packages/contracts/src/sync-leaks.ts new file mode 100644 index 0000000..739442d --- /dev/null +++ b/packages/contracts/src/sync-leaks.ts @@ -0,0 +1,95 @@ +/** + * Sync-leak scanner: secrets and absolute local paths must never leave a + * machine inside anything SkillX uploads or syncs (collection revisions, + * lockfiles). The scan walks a JSON value and reports `{path, rule}` pairs; + * it never returns the matched text, so a finding can be logged safely. + * + * Rules: + * - `secret-key`: an object key that names a password or secret (shared + * policy normalization, `secretKeyKind`) holding a non-empty string; + * - `secret-token`: a value that looks like a credential (private key block, + * `Bearer` token, URL with `user:pass@`, JWT, well-known vendor prefixes, + * SkillX API keys); + * - `absolute-path`: a value that is, or embeds, an absolute machine path + * (`/home/..`, `/Users/..`, `C:\..`, `\\host\..`, `~/..`, `file://`). + * Release-relative paths (`scripts/run.sh`) and URLs pass. + */ +import { secretKeyKind } from "./personal-kit/proposal-policy"; + +export type SyncLeakRule = "secret-key" | "secret-token" | "absolute-path"; + +export interface SyncLeak { + /** JSON path of the offending member, e.g. `config.token` or `packages[2].alias`. */ + path: string; + rule: SyncLeakRule; +} + +const TOKEN_RULES: readonly RegExp[] = [ + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/, + /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, + /\b[a-z][a-z0-9+.-]{0,31}:\/\/[^\s/?#:@]+:[^\s/?#@]+@/i, + /\beyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/, + /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{40,}\b/, + /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, + /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/, + /\bAIza[0-9A-Za-z_-]{35}\b/, + /\bsx_[A-Za-z0-9_-]{16,}\b/, +]; + +/** Whole-value or embedded absolute machine paths. */ +const PATH_RULES: readonly RegExp[] = [ + /^\//, + /^~[\\/]/, + /^[A-Za-z]:[\\/]/, + /^\\\\/, + /\bfile:\/\//i, + /(?:^|[\s"'=(,;])(?:\/(?:home|Users|root|var|tmp|private|opt|etc|mnt|Volumes)\/)/, + /(?:^|[\s"'=(,;])[A-Za-z]:\\/, +]; + +/** True when `text` holds a credential-looking token. */ +export function hasSecretToken(text: string): boolean { + return TOKEN_RULES.some((re) => re.test(text)); +} + +/** True when `text` is or embeds an absolute local path. */ +export function hasAbsolutePath(text: string): boolean { + return PATH_RULES.some((re) => re.test(text)); +} + +const MAX_DEPTH = 64; + +/** Every leak in `value`, in document order. Deeper than 64 levels is reported as a leak at that path. */ +export function findSyncLeaks(value: unknown, path = ""): SyncLeak[] { + const out: SyncLeak[] = []; + walk(value, path, 0, out); + return out; +} + +function walk(value: unknown, path: string, depth: number, out: SyncLeak[]): void { + if (depth > MAX_DEPTH) { + out.push({ path, rule: "secret-token" }); + return; + } + if (typeof value === "string") { + if (hasSecretToken(value)) out.push({ path, rule: "secret-token" }); + else if (hasAbsolutePath(value)) out.push({ path, rule: "absolute-path" }); + return; + } + if (Array.isArray(value)) { + value.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1, out)); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value)) { + const childPath = path === "" ? key : `${path}.${key}`; + if (hasAbsolutePath(key)) out.push({ path: childPath, rule: "absolute-path" }); + if (typeof child === "string" && child.length > 0 && secretKeyKind(key) !== null) { + out.push({ path: childPath, rule: "secret-key" }); + continue; + } + walk(child, childPath, depth + 1, out); + } +} diff --git a/plans/261019-1914-issues-31-88-delivery/phase3-design.md b/plans/261019-1914-issues-31-88-delivery/phase3-design.md new file mode 100644 index 0000000..8a52018 --- /dev/null +++ b/plans/261019-1914-issues-31-88-delivery/phase3-design.md @@ -0,0 +1,93 @@ +# Phase 3 (#35) design: collections API, resolver, lockfile, installer, sync, local MCP + +Status: accepted for implementation (2026-10-19). Scope: issue #35 checklist; #55 data +model is already on `main` and is reused, not re-implemented. + +## What already exists and is reused + +| Need | Existing piece | Decision | +|------|----------------|----------| +| Kit identity, immutable revisions, CAS, visibility, activation, lock | Personal kit API (`/api/v1/kits/*`, `kit-lock.ts`, `skillx.personal-kit-lock/v1`) | Reused as the **kit** source for the resolver. No new kit endpoints. | +| Pinned bytes | Release read API (`/api/v1/releases/:digest/{manifest,files/*}?version=&package=`) | The only byte source of the installer. | +| Manifest signatures | `verifyReleaseManifest` (contracts) | Client verifies with embedded/configured trusted keys, fail closed. | +| Overlay composition | `applyOverlay`, `memoryTree` (contracts) | Kit entries with an overlay are composed client side and must hash to `compositionDigest`. | +| Collections tables | `collections`, `collection_revisions` (migration 0009), `validateCollectionMember` | Reused; one additive migration. | +| Compatibility engine | `normalizeCompatibility` (contracts) | Used for package declarations found in the tree (`skillx.json`); kit `runtimes` gate the target. | + +## 1. Collections web API (`SKILLX_COLLECTIONS_API`, 404 when off) + +A collection is a recipe of package references (no overlays). Kits stay the +personal, overlay-capable unit; collections are the shareable list. + +- `GET /api/v1/collections` (own list), `POST /api/v1/collections` (create; revision 1). +- `GET|PATCH|DELETE /api/v1/collections/:id` (metadata: `name`, `visibility`). +- `GET|POST /api/v1/collections/:id/revisions`, `GET .../revisions/:n` (full-replace revision). +- `POST /api/v1/collections/:id/members` (`add[]`, `remove[]` → new revision). +- `POST /api/v1/collections/:id/clone` (new private collection of the caller, lineage kept). +- Writes: owner via session or unscoped API key, `RELEASE_WRITE_LIMITER`; CAS by + `expectedRevision` (409 `collection.revision_conflict`). Reads: private → owner only + (others 404), `unlisted`/`public` → anyone. +- Revisions immutable: DB trigger aborts any UPDATE; `revision_digest` = sha256 of + canonical `{members, config}`. +- `config` and members pass the shared **sync-leak scanner** (secrets, absolute paths) → 422. + +Migration `0019_collection_revision_lineage.sql` (additive, rollback SQL in header): +`collections.cloned_from_revision_id`, `collection_revisions.revision_digest`, trigger +`collection_revisions_immutable`. + +## 2. Lockfile `skillx.lock/v1` (contracts `install-lock.ts`) + +Canonical JSON, no timestamps, no machine paths. `source` = kit revision, collection +revision or explicit packages; `runtimes` (kit runtime constraints); `packages[]` sorted by alias with +`package, version, releaseDigest, compositionDigest, overlayDigests, alias`. Validator + +`lockDigest`. `findSyncLeaks(value)` (also in contracts) is run before a lock is written, +read for sync, or a collection is stored. + +## 3. Installer (CLI `src/lib/install/`) + +- **Adapters**: `claude-code` → `$CLAUDE_CONFIG_DIR/skills` or `~/.claude/skills`; + `codex` → `$CODEX_HOME/skills` or `~/.codex/skills`. Home from `os.homedir()` + (HOME / USERPROFILE). State root `~/.skillx` (`SKILLX_HOME` override). +- **Resolve** → lock. **Plan** per package: `install | update | unchanged | remove | conflict`. +- **Fetch/verify**: signed manifest (trusted key, identity, version, digest), every file + sha256 + size, tree digest; overlay applied and checked against `compositionDigest`. + Content-addressed blob cache `~/.skillx/cache/blobs`. +- **Atomic apply**: stage in `/.skillx-staging/` (sibling of `skills/`), journal, rename old → backup, + stage → target, write receipt, drop backup, clear journal. Recovery at start of every + run rolls forward (target present, receipt from journal) or back (restore backup). +- **Receipts** `~/.skillx/receipts//.json` (`skillx.install-receipt/v1`): + package, digests, owned files with sha256, `requiredBy` owner keys (ref-count). +- **Ownership proof**: delete only receipt-listed files whose current sha256 matches. + Modified/unknown files are kept and reported; a dir with unknown content is never replaced + silently (conflict unless `--force` which still preserves unknown files). +- **Uninstall** removes one owner key; files go only when `requiredBy` becomes empty. + +## 4. Commands + +`skillx install ` (`kit:@h/k[#revId]`, `collection:[#n]`, `@p/s[@ver]`), +`skillx update [ref]`, `skillx uninstall `, `skillx list`, `skillx sync --locked +[--lock file] [--from kit:@h/k#rev]`, `skillx mcp`. All accept `--runtime`, `--json`; +install/update/sync accept `--dry-run`. `sync --locked` refuses drift: lock digest +mismatch vs remote revision, local modifications, any digest mismatch while fetching. + +## 5. Local stdio MCP (`skillx mcp`) + +Newline-delimited JSON-RPC 2.0 (MCP stdio): `initialize`, `tools/list`, `tools/call`, +`ping`. Tools: `inspect` (adapters, receipts), `plan` (ref, runtime → plan + `planDigest`), +`apply` (requires `confirm: true`; optional `planDigest` must match the fresh plan). + +## Tests + +Contracts (lock, leak scanner), web collections API over FakeD1, adapters/paths, verify +(tampered file, bad signature, wrong identity), apply/update/uninstall/ref-count/preserve, +interruption at every journal step then rerun converges, E2E machine A → kit revision on the +in-memory server → machine B identical digests, MCP confirm gate. Every test uses temp +HOME/USERPROFILE/CODEX_HOME/SKILLX_HOME. + +## Out of scope / follow-ups + +- Production registry public key is not in the repo: the CLI ships an empty trust store + and reads `~/.skillx/trusted-keys.json` / `SKILLX_TRUSTED_KEYS`; embedding the + production key needs the operator to publish it. +- Dependency graphs between packages: releases declare none today; resolver reports + duplicates/alias collisions only. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9a2f03..5b7576f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: specifier: ^2.9.1 version: 2.9.1 devDependencies: + '@skillx/contracts': + specifier: workspace:* + version: link:../contracts '@skillx/skill-tree': specifier: workspace:* version: link:../skill-tree