/** * Control-plane package registry client (read-only). * ================================================== * * The ONE authed HTTP client for the CP package registry (`/api/packages`). * Before this file there was NO `/api/packages` client in the CLI — `launch` * read the whole catalog from the bundled `@synap-core/workspace-templates` * package. The ratified architecture is: PUBLIC templates ship in the bundle, * PRIVATE templates live in the CP behind the user's login. This client is the * private half. * * Two list endpoints, one detail endpoint: * • GET /api/packages — public browse rows (no auth, `definition` stripped) * • GET /api/packages/mine — the caller's own packages (Bearer, incl. private) * • GET /api/packages/:slug — full row WITH `definition` (Bearer → private visible to author) * * The row→`RemoteEntry` mapping is NOT here — it is the canonical * `rowToRemoteEntry` in `@synap-core/workspace-templates` (browser + CLI share * the ONE copy; the two hand-copies had already drifted). This file owns only * TRANSPORT: the Bearer token, the base URL (`auth.ts`'s `getCpUrl()`), and the * query params the CP `/api/packages` route supports (`search`/`category`/`tag`/ * `verified`). */ import { type CpPackageRow, type RemoteEntry, type RemoteSourceStatus } from "@synap-core/workspace-templates"; import type { PackageDefinitionLike } from "./template-file.js"; /** * The server-side filters `GET /api/packages` accepts (CP `routes/packages.ts`). * `category` is a PACKAGE_TYPES value (`workspace`/`capability`/`skill`/…). The * `/mine` route supports none of these — it is the caller's own small set — so * they are applied CLIENT-side there. */ export interface PackageFilters { search?: string; category?: string; tag?: string; verified?: boolean; } /** Tier / pricing signal the browse route carries but `RemoteEntry` drops. */ export interface TierInfo { requiredTier?: string | null; pricingModel?: string | null; } /** * A public browse row — the canonical `CpPackageRow` PLUS the tier/pricing and * count fields the browse route (`GET /api/packages`) returns but the shared * `RemoteEntry` shape does not carry. Kept local because they are a * discovery-surface concern, not part of the merge door's contract. */ export interface CpBrowseRow extends CpPackageRow { version?: string; requiredTier?: string | null; pricingModel?: string | null; isVerified?: boolean; installCount?: number; } /** `/mine` returns `isPublic` (and, like the browse route, `version`) on top of the canonical row shape. */ export type CpMineRow = CpPackageRow & { isPublic?: boolean; version?: string; }; /** * GET /api/packages — public browse rows, RAW (incl. `requiredTier`/ * `pricingModel`/`category`). No auth. Throws on non-2xx / network. This is the * source both the merged launch catalog and the `market` command read from. */ export declare function fetchPublicBrowseRows(filters?: PackageFilters): Promise; /** GET /api/packages — public workspace templates as `RemoteEntry`. No auth. */ export declare function fetchPublicPackages(filters?: PackageFilters): Promise; /** * GET /api/packages/mine — the caller's own packages (incl. private). Bearer * required. The route has no `search`/`category` params, so those filters are * applied client-side over this (small) set; `search` is honored, `category` * only when the row actually carries one (the `/mine` select omits it, so a * type filter never silently drops the user's own rows). */ export declare function fetchMyRows(token: string, filters?: PackageFilters): Promise; export declare function fetchMyPackages(token: string, filters?: PackageFilters): Promise; /** * GET /api/packages/available — the slugs THIS account's subscription tier can * install (Bearer required; the route reads the user's `subscriptions.tier`). * Used to compute the "locked" set: a public package that declares a * `requiredTier` but is absent here is ungrantable for this account. Only * `category` is a real filter on this route — the others are ignored server-side. */ export declare function fetchAvailableSlugs(token: string, filters?: PackageFilters): Promise>; /** * GET /api/packages/:slug — the full row, INCLUDING `definition` (the install * payload). Authed when a token is given, which is REQUIRED for a private * template (the author-only visibility gate 404s otherwise). Returns `null` if * the package is absent / not visible. */ export declare function fetchPackageDefinition(slug: string, token?: string): Promise | null>; /** The remote half of the catalog, plus the health `mergeCatalog` needs. */ export interface RemoteCatalog { rows: RemoteEntry[]; status: RemoteSourceStatus; loggedIn: boolean; email?: string; /** How many private rows we actually fetched (meaningful only when status === "ok"). */ privateCount: number; /** Tier/pricing per public slug — the browse route carries it, `RemoteEntry` drops it. */ tierBySlug: Map; /** * Slugs that declare a `requiredTier` this account CANNOT install (browse ∖ * `/available`). Empty when logged out or when `/available` couldn't be read * (older pod / failure) — absence means "unknown", never "locked". */ lockedSlugs: Set; } /** * Assemble the remote catalog for `launch`, degrading HONESTLY rather than * failing open empty: * * • Logged out → NO network. The bundle already IS the public catalog, and * private templates are invisible without auth. Status "unauthenticated" * tells the consumer to render the `synap login` path. * • Logged in → fetch `/mine` (private + own public) and best-effort `/packages` * (official / third-party public); merge, private-carrying rows win a tie. * • `/mine` 401/403 → "unauthenticated" (session expired). Network failure → * "unreachable". Either way `rows` is empty and the door keeps the bundle. */ export declare function fetchRemoteCatalog(filters?: PackageFilters): Promise; /** * A CP write failure carrying the server's OWN message + status, so the caller * can surface `detail` (never swallow it — the `readErrorBody`/`detail` lesson) * and special-case a 403 reserved-slug rejection. */ export declare class CpWriteError extends Error { readonly status: number; readonly serverMessage: string; constructor(status: number, serverMessage: string); } /** Test seams — inject a fetch + token + base URL instead of touching the network / `~/.synap`. */ export interface CpWriteDeps { fetchImpl?: typeof fetch; token?: string; cpUrl?: string; } export interface PublishResult { /** `created` (201) · `updated` (200, content changed) · `no-op` (200, identical content). */ outcome: "created" | "updated" | "no-op"; slug: string; displayName: string; /** The `h-` version the CP derived from the definition. */ version: string; isPublic: boolean; } /** * `POST /api/packages` — publish (upsert) a package definition under the caller's * account. `isPublic` decides visibility (default private — the command flips it * with `--public`). The version is DERIVED server-side from the definition, so * identical content re-published is a no-op. Surfaces a 403 reserved-slug * rejection (and any other failure) as a `CpWriteError` carrying the server's * own message. * * The definition's identity (`slug`, `displayName`) is read off its `_meta`/ * `workspaceName`; `_meta.icon`/`.color`/`.domain` are hoisted onto the * definition's top level because the CP reads those off `definition.icon`/… for * the list DTO (it strips the unknown `_meta` key). */ export declare function publishPackage(def: PackageDefinitionLike, opts: { isPublic: boolean; }, deps?: CpWriteDeps): Promise; /** * `PATCH /api/packages/:slug` — owner-gated flip of a package to private. * * ⚠️ TODO(cp): the CP PATCH handler's body validator is * `z.object({ podId: z.string().min(1).nullable() })` TODAY — it does NOT accept * `isPublic`, so this call currently fails (400) until the CP adds `isPublic` to * that PATCH allow-list. This is deliberately the INTENDED endpoint (not a * faked flip): it will start working the moment the CP door is widened. The * command surfaces the CP's message honestly meanwhile. */ export declare function unpublishPackage(slug: string, deps?: CpWriteDeps): Promise<{ slug: string; isPublic: boolean; }>;