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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/shared/src/cli/commands/registry/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function resourceRow(type: string): ResourceRequirementRow {
describe("resolveItems", () => {
it("returns requested items in order", async () => {
const fetch = vi.fn(async (name: string) => item(name));
const result = await resolveItems(["a", "b"], null, fetch);
const result = await resolveItems(["a", "b"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b"]);
});

Expand All @@ -101,7 +101,7 @@ describe("resolveItems", () => {
c: item("c"),
};
const fetch = vi.fn(async (name: string) => graph[name]);
const result = await resolveItems(["a"], null, fetch);
const result = await resolveItems(["a"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b", "c"]);
});

Expand All @@ -112,7 +112,7 @@ describe("resolveItems", () => {
shared: item("shared"),
};
const fetch = vi.fn(async (name: string) => graph[name]);
const result = await resolveItems(["a", "b"], null, fetch);
const result = await resolveItems(["a", "b"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b", "shared"]);
expect(fetch).toHaveBeenCalledTimes(3);
});
Expand All @@ -123,7 +123,7 @@ describe("resolveItems", () => {
b: item("b", { registryDependencies: ["a"] }),
};
const fetch = vi.fn(async (name: string) => graph[name]);
const result = await resolveItems(["a"], null, fetch);
const result = await resolveItems(["a"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b"]);
expect(fetch).toHaveBeenCalledTimes(2);
});
Expand All @@ -134,7 +134,7 @@ describe("resolveItems", () => {
b: item("b"),
};
const fetch = vi.fn(async (name: string) => graph[name]);
const result = await resolveItems(["a"], null, fetch);
const result = await resolveItems(["a"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b"]);
});

Expand All @@ -146,7 +146,7 @@ describe("resolveItems", () => {
...item("analytics"),
files: [],
}));
const result = await resolveItems(["evil"], null, fetch);
const result = await resolveItems(["evil"], fetch);
expect(result.map((i) => i.name)).toEqual(["evil"]);
});

Expand All @@ -155,7 +155,7 @@ describe("resolveItems", () => {
it("rejects a top-level ref that is not a plain slug (no fetch)", async () => {
const fetch = vi.fn();
await expect(
resolveItems(["../../attacker/repo/payload"], null, fetch),
resolveItems(["../../attacker/repo/payload"], fetch),
).rejects.toThrow(/Invalid registry item name/);
expect(fetch).not.toHaveBeenCalled();
});
Expand All @@ -165,7 +165,7 @@ describe("resolveItems", () => {
a: item("a", { registryDependencies: ["../../evil"] }),
};
const fetch = vi.fn(async (name: string) => graph[name]);
await expect(resolveItems(["a"], null, fetch)).rejects.toThrow(
await expect(resolveItems(["a"], fetch)).rejects.toThrow(
/Invalid registry item name/,
);
});
Expand All @@ -180,7 +180,7 @@ describe("resolveItems", () => {
active--;
return item(name);
});
const result = await resolveItems(["a", "b", "c"], null, fetch);
const result = await resolveItems(["a", "b", "c"], fetch);
expect(result.map((i) => i.name)).toEqual(["a", "b", "c"]);
expect(maxActive).toBeGreaterThan(1); // ran in parallel, not one-at-a-time
});
Expand Down Expand Up @@ -216,7 +216,7 @@ describe("partitionVerified", () => {
"evil-dep": item("evil-dep"),
};
const fetch = vi.fn(async (name: string) => graph[name]);
const items = await resolveItems(["verified-a"], null, fetch);
const items = await resolveItems(["verified-a"], fetch);
const res = partitionVerified(
items.map((i) => i.name),
new Set(["verified-a"]), // only the top-level item is verified
Expand Down
28 changes: 6 additions & 22 deletions packages/shared/src/cli/commands/registry/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ import {
validateBundle,
writeConfig,
} from "./config-writer";
import {
JS_IDENTIFIER,
REGISTRY_REPO,
type RegistryToken,
resolveToken,
} from "./constants";
import { JS_IDENTIFIER, REGISTRY_REPO } from "./constants";
import { parseEnv } from "./env-reconcile";
import {
extractRequirements,
Expand Down Expand Up @@ -277,11 +272,7 @@ interface PluginSummary {
*/
export async function resolveItems(
names: string[],
token: RegistryToken | null,
fetchItem: (
name: string,
token: RegistryToken | null,
) => Promise<RegistryItem> = fetchRegistryItem,
fetchItem: (name: string) => Promise<RegistryItem> = fetchRegistryItem,
): Promise<RegistryItem[]> {
const seen = new Set<string>();
const ordered: RegistryItem[] = [];
Expand All @@ -307,7 +298,7 @@ export async function resolveItems(
while (level.length > 0) {
const items = await Promise.all(
level.map(async (key) => {
const item = await fetchItem(key, token);
const item = await fetchItem(key);
// Pin to the fetch key: the body's self-reported `name` is untrusted
// and could claim a verified name to slip past the gate. The key is the
// trustworthy identity the index keys `verified` on.
Expand Down Expand Up @@ -370,20 +361,14 @@ export function partitionVerified(

async function runAdd(refs: string[], opts: AddOptions): Promise<void> {
const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
const token = resolveToken();
if (token) {
console.log(
`Using ${token.envName} to fetch from ${REGISTRY_REPO} (private).`,
);
}

// Resolve the full graph and fetch the verified index concurrently (two
// independent round-trips). Item resolution is read-only — nothing is written
// or installed until after the gate below.
const verifiedP = opts.allowUnverified
? Promise.resolve(null)
: fetchVerifiedNames(token);
const items = await resolveItems(refs, token);
: fetchVerifiedNames();
const items = await resolveItems(refs);

// Integrity gate over the *entire resolved set* (not just requested names, so
// an unverified transitive dep can't ride in on a verified item). Fails closed:
Expand Down Expand Up @@ -683,8 +668,7 @@ entries are never clobbered. Interactive by default; pass --yes for agents/CI
values non-interactively. Pass --profile to validate the bundle after writing.

The frontend/server roots are detected from common layouts, so you can run
this from the repo root. While the registry repo is private, a read token is
resolved from \`gh auth token\` or APPKIT_REGISTRY_TOKEN / GITHUB_TOKEN / GH_TOKEN.
this from the repo root.

Examples:
$ appkit add metric-card # UI component
Expand Down
56 changes: 7 additions & 49 deletions packages/shared/src/cli/commands/registry/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import process from "node:process";

import {
REGISTRY_INDEX_API_URL,
REGISTRY_INDEX_URL,
REGISTRY_ITEM_API_TEMPLATE,
REGISTRY_ITEM_URL_TEMPLATE,
REGISTRY_NAMESPACE,
REGISTRY_REPO,
type RegistryToken,
} from "./constants";

export interface RegistryItemFile {
Expand Down Expand Up @@ -52,39 +49,15 @@ export function isValidItemName(name: string): boolean {
}

/**
* Auth headers for a registry request. With a token the GitHub Contents API is
* used and `Accept: raw` makes it return file bytes directly; without one the
* public raw URL needs no headers. Single source for the auth contract shared
* by every registry fetch.
* Fetches and parses a single registry item from the public raw URL. Exits the
* process with a helpful message on failure.
*/
export function registryAuthHeaders(
token: RegistryToken | null,
): Record<string, string> {
if (!token) return {};
return {
Authorization: `Bearer ${token.value}`,
Accept: "application/vnd.github.raw",
};
}

/**
* Fetches and parses a single registry item. When a token is present the GitHub
* Contents API is used (works for the private/internal repo); otherwise the
* public raw URL is used. Exits the process with a helpful message on failure.
*/
export async function fetchRegistryItem(
name: string,
token: RegistryToken | null,
): Promise<RegistryItem> {
const template = token
? REGISTRY_ITEM_API_TEMPLATE
: REGISTRY_ITEM_URL_TEMPLATE;
const url = template.replace("{name}", name);
const headers = registryAuthHeaders(token);
export async function fetchRegistryItem(name: string): Promise<RegistryItem> {
const url = REGISTRY_ITEM_URL_TEMPLATE.replace("{name}", name);

let res: Awaited<ReturnType<typeof fetch>>;
try {
res = await fetch(url, { headers });
res = await fetch(url);
} catch (err) {
console.error(`Failed to fetch "${name}" from ${url}`);
console.error(` ${err instanceof Error ? err.message : String(err)}`);
Expand All @@ -93,18 +66,6 @@ export async function fetchRegistryItem(

if (res.status === 404) {
console.error(`"${name}" not found in ${REGISTRY_REPO}.`);
if (!token) {
console.error(
" If the registry repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.",
);
}
process.exit(1);
}
if (res.status === 401 || res.status === 403) {
console.error(
`Access denied (HTTP ${res.status}) fetching "${name}" from ${REGISTRY_REPO}.`,
);
console.error(" Check that your token has read access to the repository.");
process.exit(1);
}
if (!res.ok) {
Expand All @@ -129,12 +90,9 @@ export interface RegistryIndexEntry {
* index can't be read, so the caller can tell "nothing verified" apart from
* "couldn't check".
*/
export async function fetchVerifiedNames(
token: RegistryToken | null,
): Promise<Set<string> | null> {
const url = token ? REGISTRY_INDEX_API_URL : REGISTRY_INDEX_URL;
export async function fetchVerifiedNames(): Promise<Set<string> | null> {
try {
const res = await fetch(url, { headers: registryAuthHeaders(token) });
const res = await fetch(REGISTRY_INDEX_URL);
if (!res.ok) return null;
const data = (await res.json()) as { items?: RegistryIndexEntry[] };
const verified = new Set<string>();
Expand Down
47 changes: 1 addition & 46 deletions packages/shared/src/cli/commands/registry/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { spawnSync } from "node:child_process";

/** shadcn registry namespace consumers reference, e.g. `@databricks-appkit/metric-card`. */
export const REGISTRY_NAMESPACE = "@databricks-appkit";

Expand All @@ -15,52 +13,9 @@ export const REGISTRY_REPO = "databricks/appkit-registry";
export const REGISTRY_REF = "main";

/**
* Public hosting: once the repo is public, items are fetchable directly from
* The registry is a public repo, so items are fetched directly from
* raw.githubusercontent.com with no auth.
*/
const PUBLIC_RAW_BASE = `https://raw.githubusercontent.com/${REGISTRY_REPO}/${REGISTRY_REF}`;
export const REGISTRY_ITEM_URL_TEMPLATE = `${PUBLIC_RAW_BASE}/public/r/{name}.json`;
export const REGISTRY_INDEX_URL = `${PUBLIC_RAW_BASE}/registry.json`;

/**
* Private/internal hosting: while the repo is internal, files are fetched via
* the GitHub Contents API with a token. `Accept: application/vnd.github.raw`
* makes the API return the file bytes directly (the registry-item JSON).
*/
const GH_CONTENTS_API = `https://api.github.com/repos/${REGISTRY_REPO}/contents`;
export const REGISTRY_ITEM_API_TEMPLATE = `${GH_CONTENTS_API}/public/r/{name}.json?ref=${REGISTRY_REF}`;
export const REGISTRY_INDEX_API_URL = `${GH_CONTENTS_API}/registry.json?ref=${REGISTRY_REF}`;

/** Env vars checked (in order) for a token granting read access to the repo. */
export const TOKEN_ENV_VARS = [
"APPKIT_REGISTRY_TOKEN",
"GITHUB_TOKEN",
"GH_TOKEN",
];

export interface RegistryToken {
envName: string;
value: string;
}

/**
* Resolves a token granting read access to the registry repo: first the env
* vars in {@link TOKEN_ENV_VARS}, then the GitHub CLI (`gh auth token`) if the
* user is logged in. Returns null if none are available.
*/
export function resolveToken(
env: NodeJS.ProcessEnv = process.env,
): RegistryToken | null {
for (const envName of TOKEN_ENV_VARS) {
const value = env[envName];
if (value) return { envName, value };
}
try {
const res = spawnSync("gh", ["auth", "token"], { encoding: "utf-8" });
const value = res.status === 0 ? res.stdout.trim() : "";
if (value) return { envName: "gh auth token", value };
} catch {
// gh not installed or not on PATH — fall through.
}
return null;
}
4 changes: 1 addition & 3 deletions packages/shared/src/cli/commands/registry/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import { Command } from "commander";
import pc from "picocolors";

import { fetchRegistryItem, isValidItemName, stripNamespace } from "./client";
import { resolveToken } from "./constants";
import { extractRequirements, renderRequirements } from "./requirements";

async function runInfo(ref: string, opts: { json?: boolean }): Promise<void> {
const token = resolveToken();
// Validate before fetching: the name is interpolated into the fetch path, so
// reject non-slug refs (matches the `add` guard) rather than let `/` or `..`
// redirect the request to another path in the repo.
Expand All @@ -17,7 +15,7 @@ async function runInfo(ref: string, opts: { json?: boolean }): Promise<void> {
console.error(`Invalid registry item name: ${JSON.stringify(ref)}`);
process.exit(1);
}
const item = await fetchRegistryItem(name, token);
const item = await fetchRegistryItem(name);
const rows = extractRequirements(item);

if (opts.json) {
Expand Down
30 changes: 7 additions & 23 deletions packages/shared/src/cli/commands/registry/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@ import process from "node:process";
import { Command } from "commander";
import pc from "picocolors";

import { registryAuthHeaders } from "./client";
import {
REGISTRY_INDEX_API_URL,
REGISTRY_INDEX_URL,
REGISTRY_REPO,
resolveToken,
} from "./constants";
import { REGISTRY_INDEX_URL, REGISTRY_REPO } from "./constants";

interface RegistryIndexItem {
name: string;
Expand Down Expand Up @@ -111,34 +105,24 @@ function printTable(items: RegistryIndexItem[]): void {
}
}

/** Fetches the registry index (token-aware), or exits with a helpful message. */
/** Fetches the registry index from the public raw URL, or exits with a message. */
async function fetchIndex(): Promise<RegistryIndexItem[]> {
const token = resolveToken();
const url = token ? REGISTRY_INDEX_API_URL : REGISTRY_INDEX_URL;

let res: Awaited<ReturnType<typeof fetch>>;
try {
res = await fetch(url, { headers: registryAuthHeaders(token) });
res = await fetch(REGISTRY_INDEX_URL);
} catch (err) {
console.error(pc.red(`Failed to reach the registry at ${url}`));
console.error(
pc.red(`Failed to reach the registry at ${REGISTRY_INDEX_URL}`),
);
console.error(` ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
if (res.status === 404 || res.status === 401 || res.status === 403) {
if (!res.ok) {
console.error(
pc.red(
`Could not read the registry index from ${REGISTRY_REPO} (HTTP ${res.status}).`,
),
);
if (!token) {
console.error(
" If the repo is private, set APPKIT_REGISTRY_TOKEN (or GITHUB_TOKEN) to a token with read access.",
);
}
process.exit(1);
}
if (!res.ok) {
console.error(pc.red(`Registry returned HTTP ${res.status} for ${url}`));
process.exit(1);
}

Expand Down
Loading