Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.3.1

- fix: the plugin's exported API (`contentCliPlugin`, `toFieldSchemas`, `getEntitySchema`, `getBlockSchema`, `listReadableEntities`) is now typed against Payload's own `Config`/`Field`/`PayloadRequest` shapes instead of `any`, so consumers get type-checking and autocomplete. `payload` is declared as an optional peer dependency and imported type-only — nothing is added to the runtime.

## 0.3.0

- feat: the plugin entry now exports the schema API for building custom tools (e.g. `listEntities` + `getEntitySchema` + `getBlockSchema` MCP tools) without going through HTTP. `listReadableEntities({ req })` returns the readable collection/global slugs plus localization; `getEntitySchema({ req, type, slug })` returns the same `{ slug, fields, jsonSchema }` the `/schema` endpoint produces for one entity; `getBlockSchema({ req, slugs })` resolves richText block slugs to `{ slug, fields }`.
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
"dotenv": "^17.4.2",
"zod": "^4.4.3"
},
"peerDependencies": {
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": {
"optional": true
}
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
Expand All @@ -75,6 +83,7 @@
"lint-staged": "^17.0.5",
"oxfmt": "^0.50.0",
"oxlint": "^1.65.0",
"payload": "3.84.1",
"tsx": "^4.22.0",
"typescript": "^6.0.3",
"vitest": "4.1.6"
Expand Down
34 changes: 0 additions & 34 deletions plans/012-type-payload-shapes-drop-any.md

This file was deleted.

3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions src/__tests__/cli-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => {

it("push exits with code 2 on conflict", async () => {
const dir = path.join(CONTENT_DIR, "collections", "posts");
const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json") && !f.startsWith("_"));
const files = (await fs.readdir(dir)).filter(
(file) => file.endsWith(".json") && !file.startsWith("_"),
);
expect(files.length).toBeGreaterThan(0);
const target = path.join(dir, files[0]);

Expand Down Expand Up @@ -79,7 +81,7 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => {
expect(localized.status, localized.stderr).toBe(0);

const dir = path.join(CONTENT_DIR, "collections", "posts");
const enFiles = (await fs.readdir(dir)).filter((f) => f.endsWith("_en.json"));
const enFiles = (await fs.readdir(dir)).filter((file) => file.endsWith("_en.json"));
expect(enFiles.length).toBeGreaterThan(1);

// Locally edit one file before the next pull
Expand All @@ -96,7 +98,7 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => {
expect(repulled.stdout).toMatch(/Kept 1 orphan file/);

// Clean orphans (non-edited) gone, edited orphan preserved
const remaining = (await fs.readdir(dir)).filter((f) => f.endsWith("_en.json"));
const remaining = (await fs.readdir(dir)).filter((file) => file.endsWith("_en.json"));
expect(remaining).toEqual([enFiles[0]]);
});

Expand Down
49 changes: 29 additions & 20 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function cleanup() {

async function readJsonDir(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir);
return entries.filter((f) => f.endsWith(".json") && !f.startsWith("_"));
return entries.filter((file) => file.endsWith(".json") && !file.startsWith("_"));
}

async function readJson(filePath: string): Promise<Record<string, unknown>> {
Expand Down Expand Up @@ -72,7 +72,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
expect(schema.slug).toBe("posts");

const fields = schema.fields as Array<{ name: string; type: string }>;
const titleField = fields.find((f) => f.name === "title");
const titleField = fields.find((field) => field.name === "title");
expect(titleField).toBeDefined();
expect(titleField!.type).toBe("text");
});
Expand All @@ -84,13 +84,13 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
virtual?: boolean;
}>;

const pathField = fields.find((f) => f.name === "path");
const pathField = fields.find((field) => field.name === "path");
expect(pathField?.virtual).toBe(true);

const breadcrumbsField = fields.find((f) => f.name === "breadcrumbs");
const breadcrumbsField = fields.find((field) => field.name === "breadcrumbs");
expect(breadcrumbsField?.virtual).toBe(true);

const titleField = fields.find((f) => f.name === "title");
const titleField = fields.find((field) => field.name === "title");
expect(titleField?.virtual).toBeUndefined();
});

Expand Down Expand Up @@ -208,7 +208,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {

const result = await status(config);
expect(result!.added.length).toBeGreaterThanOrEqual(1);
expect(result!.added.some((a) => a.includes("test-new-status"))).toBe(true);
expect(result!.added.some((addedPath) => addedPath.includes("test-new-status"))).toBe(true);

await fs.unlink(newFile);
});
Expand Down Expand Up @@ -291,7 +291,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
limit: 100,
});
const created = response.docs.find(
(d) => (d as Record<string, unknown>).slug === "push-create-test",
(doc) => (doc as Record<string, unknown>).slug === "push-create-test",
) as Record<string, unknown> | undefined;
expect(created).toBeDefined();
expect(created!.name).toBe("Push Create Test");
Expand Down Expand Up @@ -347,8 +347,8 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
// After push, manifest is updated — but note the file content still differs
// from what the server returns (we wrote it, server may add fields)
const manifest = await loadManifest(CONTENT_DIR);
const key = Object.keys(manifest!.documents).find((k) =>
k.includes(files[0].replace(".json", "")),
const key = Object.keys(manifest!.documents).find((candidate) =>
candidate.includes(files[0].replace(".json", "")),
);
expect(key).toBeDefined();
expect(manifest!.documents[key!].updatedAt).toBeTruthy();
Expand Down Expand Up @@ -394,12 +394,14 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
expect(Array.isArray(schema!.endpoints)).toBe(true);

// The example project registers /example-plugin/stats (GET) and /example-plugin/publish-all (POST)
const stats = schema!.endpoints!.find((ep) => ep.path === "/api/example-plugin/stats");
const stats = schema!.endpoints!.find(
(endpoint) => endpoint.path === "/api/example-plugin/stats",
);
expect(stats).toBeDefined();
expect(stats!.method).toBe("get");

const publishAll = schema!.endpoints!.find(
(ep) => ep.path === "/api/example-plugin/publish-all",
(endpoint) => endpoint.path === "/api/example-plugin/publish-all",
);
expect(publishAll).toBeDefined();
expect(publishAll!.method).toBe("post");
Expand All @@ -411,7 +413,9 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
endpoints?: { path: string }[];
} | null;

const schemaEndpoint = schema!.endpoints!.find((ep) => ep.path === "/api/content-cli/schema");
const schemaEndpoint = schema!.endpoints!.find(
(endpoint) => endpoint.path === "/api/content-cli/schema",
);
expect(schemaEndpoint).toBeUndefined();
});

Expand Down Expand Up @@ -538,8 +542,9 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
depth: 0,
});
const originalVersion = (versions.docs as Record<string, unknown>[]).find(
(v) =>
((v.version as Record<string, unknown>).excerpt as string) === "version-test-original",
(version) =>
((version.version as Record<string, unknown>).excerpt as string) ===
"version-test-original",
);
expect(originalVersion).toBeDefined();

Expand Down Expand Up @@ -613,7 +618,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
const files = await readJsonDir(postsDir);

// Files should have _de suffix
expect(files.every((f) => f.endsWith("_de.json"))).toBe(true);
expect(files.every((file) => file.endsWith("_de.json"))).toBe(true);

const post = await readJson(path.join(postsDir, files[0]));
// Localized fields must be flat strings
Expand All @@ -627,8 +632,8 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
const postsDir = path.join(CONTENT_DIR, "collections", "posts");
const files = await readJsonDir(postsDir);

const enFiles = files.filter((f) => f.endsWith("_en.json"));
const deFiles = files.filter((f) => f.endsWith("_de.json"));
const enFiles = files.filter((file) => file.endsWith("_en.json"));
const deFiles = files.filter((file) => file.endsWith("_de.json"));
expect(enFiles.length).toBeGreaterThan(0);
expect(deFiles.length).toBeGreaterThan(0);
expect(enFiles.length).toBe(deFiles.length);
Expand All @@ -640,7 +645,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {

const manifest = await loadManifest(CONTENT_DIR);
const keys = Object.keys(manifest!.documents);
expect(keys.every((k) => k.endsWith("_de.json"))).toBe(true);
expect(keys.every((key) => key.endsWith("_de.json"))).toBe(true);
});

it("omits locale from filenames and manifest keys when not specified", async () => {
Expand All @@ -650,11 +655,15 @@ describe.skipIf(!hasRemoteEnv)("integration", () => {
const postsDir = path.join(CONTENT_DIR, "collections", "posts");
const files = await readJsonDir(postsDir);
// No locale suffix
expect(files.every((f) => !f.includes("_en.json") && !f.includes("_de.json"))).toBe(true);
expect(files.every((file) => !file.includes("_en.json") && !file.includes("_de.json"))).toBe(
true,
);

const manifest = await loadManifest(CONTENT_DIR);
const keys = Object.keys(manifest!.documents);
expect(keys.every((k) => !k.includes("_en.json") && !k.includes("_de.json"))).toBe(true);
expect(keys.every((key) => !key.includes("_en.json") && !key.includes("_de.json"))).toBe(
true,
);
});
});
}); // describe.skipIf
8 changes: 4 additions & 4 deletions src/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,8 @@ describe("loadConfig with profile", () => {
apiKey: "profile-key",
});

const apiKeyWarnings = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("PAYLOAD_API_KEY"),
const apiKeyWarnings = warnSpy.mock.calls.filter((call) =>
String(call[0]).includes("PAYLOAD_API_KEY"),
);
expect(apiKeyWarnings.length).toBe(1);
} finally {
Expand All @@ -371,8 +371,8 @@ describe("loadConfig with profile", () => {
const { loadConfig } = await import("../config.js");
loadConfig(undefined, { apiKey: "same-key" });

const apiKeyWarnings = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("PAYLOAD_API_KEY"),
const apiKeyWarnings = warnSpy.mock.calls.filter((call) =>
String(call[0]).includes("PAYLOAD_API_KEY"),
);
expect(apiKeyWarnings.length).toBe(0);
} finally {
Expand Down
30 changes: 16 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ program
Utilities: ["me", "discover", "skill", "lexical", "clean", "profile"],
};

const cmds = new Map(cmd.commands.map((c: Command) => [c.name(), c]));
const cmds = new Map(cmd.commands.map((command: Command) => [command.name(), command]));

let output = `Usage: ${helper.commandUsage(cmd)}\n\n`;
output += `${cmd.description()}\n`;
Expand Down Expand Up @@ -188,13 +188,13 @@ program
const localWhere: Record<string, string> = {};
if (opts.where) {
const parsed = parseWhere(opts.where as string);
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === "object" && v !== null) {
const inner = v as Record<string, unknown>;
const val = inner.equals ?? inner.like ?? Object.values(inner)[0];
if (val !== undefined) localWhere[k] = String(val);
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "object" && value !== null) {
const inner = value as Record<string, unknown>;
const resolvedValue = inner.equals ?? inner.like ?? Object.values(inner)[0];
if (resolvedValue !== undefined) localWhere[key] = String(resolvedValue);
} else {
localWhere[k] = String(v);
localWhere[key] = String(value);
}
}
}
Expand Down Expand Up @@ -314,13 +314,15 @@ program
// Bulk update via PATCH with where params
const where = parseWhere(opts.where as string);
const whereParams: Record<string, string> = {};
for (const [k, v] of Object.entries(where)) {
if (typeof v === "object" && v !== null) {
for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
whereParams[`where[${k}][${op}]`] = String(val);
for (const [key, value] of Object.entries(where)) {
if (typeof value === "object" && value !== null) {
for (const [operator, operatorValue] of Object.entries(
value as Record<string, unknown>,
)) {
whereParams[`where[${key}][${operator}]`] = String(operatorValue);
}
} else {
whereParams[`where[${k}][equals]`] = String(v);
whereParams[`where[${key}][equals]`] = String(value);
}
}
result = await client.rawPatch(
Expand Down Expand Up @@ -553,8 +555,8 @@ program
withFileTypes: true,
});
files = entries
.filter((e) => e.isFile())
.map((e) => path.join(opts.dir as string, e.name))
.filter((entry) => entry.isFile())
.map((entry) => path.join(opts.dir as string, entry.name))
.sort();
} else {
files = [];
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const CLOUD_STORAGE_REQUIRED_SELECT_KEYS = ["filename", "mimeType", "filesize",
function preserveUploadFieldsInSelect(select: SelectType | undefined): SelectType | undefined {
if (!select) return select;
const values = Object.values(select);
const isExcludeMode = values.length > 0 && values.every((v) => v === false);
const isExcludeMode = values.length > 0 && values.every((value) => value === false);

if (isExcludeMode) {
const next: Record<string, false | SelectExcludeType> = {};
Expand Down
2 changes: 1 addition & 1 deletion src/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async function scanDir(

const fields: Record<string, string> = {};
if (options.select) {
const isExclude = Object.values(options.select).every((v) => !v);
const isExclude = Object.values(options.select).every((selected) => !selected);
if (isExclude) {
for (const key of Object.keys(doc)) {
if (!(key in options.select)) {
Expand Down
4 changes: 2 additions & 2 deletions src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { promisify } from "node:util";

const execFileP = promisify(execFile);

function shellQuote(s: string): string {
function shellQuote(value: string): string {
// POSIX single-quote escape — safe for sh -c invocations on macOS/Linux.
return `'${s.replace(/'/g, `'\\''`)}'`;
return `'${value.replace(/'/g, `'\\''`)}'`;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/lexical/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("validateTree", () => {
];
const warnings = validateTree(children);
expect(warnings.length).toBeGreaterThan(0);
expect(warnings.some((w) => w.startsWith("[0.0]"))).toBe(true);
expect(warnings.some((warning) => warning.startsWith("[0.0]"))).toBe(true);
});

it("returns empty for empty array", () => {
Expand Down
6 changes: 3 additions & 3 deletions src/lexical/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ export function parseAddress(str: string): Address {
const parts = trimmed.split(".");
const address: Address = [];
for (const part of parts) {
const n = Number(part);
if (!Number.isInteger(n) || n < 0) {
const segment = Number(part);
if (!Number.isInteger(segment) || segment < 0) {
throw new Error(`Invalid address segment "${part}" — must be a non-negative integer`);
}
address.push(n);
address.push(segment);
}
return address;
}
Expand Down
Loading