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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-repair-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds `emdash media repair-usage` and the `media_usage_repair` MCP tool for repairing content media usage indexes by collection or across all collections; automation should inspect the structured repair status because non-complete results can still be successful invocations.
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ jobs:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter emdash exec vitest run --config vitest.smoke.config.ts
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/emdash_smoke
Expand All @@ -174,7 +173,6 @@ jobs:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter emdash exec vitest run --config vitest.integration.config.ts

test-browser:
Expand Down
41 changes: 32 additions & 9 deletions docs/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,25 @@ Run commands with `npx emdash` or add scripts to `package.json`. The binary is a

## Authentication

Commands that talk to a running EmDash instance resolve authentication in this order:
Commands using the shared remote client resolve authentication in this order:

1. **`--token` flag** — explicit token on the command line
2. **`EMDASH_TOKEN` env var**
3. **Stored credentials** from `~/.config/emdash/auth.json` (saved by `emdash login`)
4. **Dev bypass** — if the URL is localhost and no token is available, automatically authenticates via the dev bypass endpoint

Most commands accept `--url` (default `http://localhost:4321`) and `--token` flags. When targeting a local dev server, no token is needed.
These commands accept `--url` (from `EMDASH_URL`, falling back to `http://localhost:4321`) and `--token` flags. Authentication commands have their own connection options. When targeting a local dev server, no token is needed.

## Common Flags

These flags are available on all remote commands:
These flags are available on commands using the shared remote client:

| Flag | Alias | Description | Default |
| --------- | ----- | --------------------------- | ------------------------ |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
| `--token` | `-t` | Auth token | From env/stored creds |
| `--json` | | Output as JSON (for piping) | Auto-detected from TTY |
| Flag | Alias | Description | Default |
| ----------------------- | ----- | -------------------------------------- | -------------------------------- |
| `--url` | `-u` | EmDash instance URL | `EMDASH_URL` or `http://localhost:4321` |
| `--token` | `-t` | Auth token | From env/stored creds |
| `--header "Name: Value"` | `-H` | Custom request header; may be repeated | From `EMDASH_HEADERS`/stored creds |
| `--json` | | Output as JSON (for piping) | Auto-detected from TTY |

## Output

Expand Down Expand Up @@ -388,6 +389,27 @@ npx emdash media get 01MEDIA123
npx emdash media delete 01MEDIA123
```

#### `media repair-usage`

Repair content media usage indexes for one collection or for every content collection. Use this after imports or direct database writes when usage coverage is stale or untrusted.

```bash
npx emdash media repair-usage --collection posts
npx emdash media repair-usage --all
npx emdash media repair-usage --all --json
```

| Option | Alias | Description |
| -------------- | ----- | ------------------------------ |
| `--collection` | `-c` | Repair one content collection |
| `--all` | | Repair every content collection |

Pass exactly one of `--collection` or `--all`. Remote repair requires an Admin user and an auth token with the `admin` scope.
Comment thread
khoinguyenpham04 marked this conversation as resolved.

All-content repair runs synchronously and can be slow or expensive on large sites. Prefer `--collection` when you only need to repair one collection.

Structured `complete`, `partial`, and `stale` repair results exit `0`; structured `failed` results exit `1`. Automation and cron jobs should use `--json` and parse `status`, `failedSourceCount`, `skippedSourceCount`, and per-collection summaries instead of treating exit `0` as complete coverage.

### `emdash search`

Full-text search across content.
Expand Down Expand Up @@ -557,7 +579,8 @@ The command also writes a raw schema export for tooling:
| ------------------------- | ---------------------------------------- |
| `EMDASH_DATABASE_URL` | Database URL (set automatically by `dev`) |
| `EMDASH_TOKEN` | Auth token for remote operations |
| `EMDASH_URL` | Default remote URL for `types` and `dev --types` |
| `EMDASH_URL` | Default URL for commands using the shared remote client |
| `EMDASH_HEADERS` | Newline-separated custom request headers for the shared remote client and `login` |
| `EMDASH_ENCRYPTION_KEY` | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. Generate with `emdash secrets generate`. |
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, EmDash generates and persists one in the options table. |
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, EmDash generates and persists one in the options table. |
Expand Down
16 changes: 15 additions & 1 deletion docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ In addition to scopes, some tools require a minimum RBAC role. Both must be sati
| Settings read | Editor (40) |
| Settings manage | Admin (50) |
| Media upload (`media_create`) | Author (30) |
| Media usage repair | Admin (50) |

See the [Authentication guide](/guides/authentication#user-roles) for role definitions.

Expand All @@ -79,7 +80,7 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma

## Tools

The server exposes 45 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.
The server exposes 52 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.

### Content Tools

Expand Down Expand Up @@ -434,6 +435,19 @@ Permanently delete a media file. Removes the database record and the file from s

**Scope:** `media:write` | **Destructive:** Yes

#### `media_usage_repair`

Repair content media usage indexes for one collection or every collection. Repair runs synchronously and can be slow or expensive on large sites; prefer collection scope when possible.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `scope` | `"collection" \| "all"` | Yes | Whether to repair one collection or all collections |
| `collection` | `string` | For collection scope | Collection slug; omit when `scope` is `all` |

The result has a structured `status` of `complete`, `partial`, `failed`, or `stale`, plus aggregate and per-collection source counts. All four statuses are successful MCP tool results, so callers must inspect `status` rather than relying on `isError`. Authentication, validation, or unexpected repair errors return `isError: true`.

**Scope:** `admin` | **Minimum role:** Admin

### Search Tool

#### `search`
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/cli/client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const connectionArgs = {
type: "string" as const,
alias: "u",
description: "EmDash instance URL",
default: "http://localhost:4321",
},
token: {
type: "string" as const,
Expand Down
127 changes: 127 additions & 0 deletions packages/core/src/cli/commands/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import { basename } from "node:path";
import { defineCommand } from "citty";
import { consola } from "consola";

import type { MediaUsageRepairInput, MediaUsageRepairResponse } from "../../client/index.js";
import { connectionArgs, createClientFromArgs } from "../client-factory.js";
import { configureOutputMode, output } from "../output.js";

const repairScopeError = "Specify exactly one of --collection or --all";

const listCommand = defineCommand({
meta: {
name: "list",
Expand Down Expand Up @@ -155,6 +158,129 @@ const deleteCommand = defineCommand({
},
});

const repairUsageCommand = defineCommand({
meta: {
name: "repair-usage",
description:
"Repair content media usage indexes. partial/stale exit 0; automation should use --json and parse status.",
},
args: {
...connectionArgs,
collection: {
type: "string",
alias: "c",
description: "Repair one content collection",
},
all: {
type: "boolean",
description: "Repair every content collection",
},
},
async run({ args }) {
configureOutputMode(args);

const hasCollectionArg = args.collection !== undefined;
const hasCollection = typeof args.collection === "string";
const hasAll = args.all === true;
if ((hasCollectionArg && !hasCollection) || hasCollection === hasAll) {
consola.error(repairScopeError);
process.exit(1);
}

const client = createClientFromArgs(args);

try {
const repairInput: MediaUsageRepairInput = hasCollection
? { scope: "collection", collection: args.collection }
: { scope: "all" };
const result = await client.mediaRepairUsage(repairInput);

if (args.json || !process.stdout.isTTY) {
output(result, args);
} else {
printRepairUsageSummary(result, repairInput);
}

if (result.status === "failed") {
process.exitCode = 1;
}
} catch (error) {
consola.error(
"Failed to repair media usage:",
error instanceof Error ? error.message : error,
);
process.exit(1);
}
},
});

type RepairUsageSummary = {
level: "success" | "warn";
message: string;
};

export function formatRepairUsageSummary(
result: MediaUsageRepairResponse,
input: MediaUsageRepairInput,
): RepairUsageSummary {
const counts = `indexed ${result.indexedSourceCount}, failed ${result.failedSourceCount}, skipped ${result.skippedSourceCount}, deleted ${result.deletedSourceCount}`;
const scope =
input.scope === "collection"
? `collection ${input.collection}`
: `all content (${formatCollectionCount(result.collections.length)})`;

if (result.status === "complete") {
return {
level: "success",
message: `Media usage repair complete for ${scope} (${counts}).`,
};
}

const details = result.collections
.filter((collection) => collection.status !== "complete")
.map((collection) => {
const error = collection.lastErrorCode ? `, ${collection.lastErrorCode}` : "";
return `${collection.collection}: ${collection.status}${error}`;
})
.join("; ");
const suffix = details ? ` ${details}.` : "";

if (result.status === "stale") {
return {
level: "warn",
message: `Media usage repair is stale for ${scope}; trustworthy complete coverage was not established because another writer, repair, or stale marker won the race. Rerun when writes are quiet (${counts}).${suffix}`,
};
}

if (result.status === "partial") {
return {
level: "warn",
message: `Media usage repair is partial for ${scope}; some sources or collections need attention (${counts}).${suffix}`,
};
}

return {
level: "warn",
message: `Media usage repair failed for ${scope} (${counts}).${suffix}`,
};
}

function printRepairUsageSummary(
result: MediaUsageRepairResponse,
input: MediaUsageRepairInput,
): void {
const summary = formatRepairUsageSummary(result, input);
if (summary.level === "success") {
consola.success(summary.message);
} else {
consola.warn(summary.message);
}
}

function formatCollectionCount(count: number): string {
return `${count} collection${count === 1 ? "" : "s"}`;
}

export const mediaCommand = defineCommand({
meta: {
name: "media",
Expand All @@ -165,5 +291,6 @@ export const mediaCommand = defineCommand({
upload: uploadCommand,
get: getCommand,
delete: deleteCommand,
"repair-usage": repairUsageCommand,
},
});
66 changes: 66 additions & 0 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
contentSeoInput,
} from "#api/schemas.js";

import type { MediaUsageRepairRequest } from "../api/schemas/media-usage.js";
import type { EmDashHandlers } from "../astro/types.js";
import { hasScope } from "../auth/api-tokens.js";
import { convertDataForRead, convertDataForWrite } from "../client/portable-text.js";
Expand Down Expand Up @@ -76,6 +77,35 @@ const settingsSeoSchema = z.object({
.describe("Bing Webmaster Tools verification token"),
});

const mediaUsageRepairToolSchema = z
.object({
scope: z.enum(["collection", "all"]).describe("Repair one collection or all collections"),
collection: z
.string()
.min(1)
.max(63)
.regex(COLLECTION_SLUG_PATTERN, "Invalid collection slug")
.optional()
.describe("Collection slug; required only when scope is collection"),
})
.strict()
.superRefine((input, ctx) => {
if (input.scope === "collection" && input.collection === undefined) {
ctx.addIssue({
code: "custom",
path: ["collection"],
message: "Collection is required when scope is collection",
});
}
if (input.scope === "all" && input.collection !== undefined) {
ctx.addIssue({
code: "custom",
path: ["collection"],
message: "Collection must be omitted when scope is all",
});
}
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1927,6 +1957,42 @@ export function createMcpServer(): McpServer {
},
);

server.registerTool(
"media_usage_repair",
{
title: "Repair Media Usage Index",
description:
"Repair content media usage indexes for one collection or every collection. " +
"Returns complete, partial, failed, or stale status; inspect the structured result.",
inputSchema: mediaUsageRepairToolSchema,
},
async (args, extra) => {
requireScope(extra, "admin");
requireRole(extra, Role.ADMIN);
const ec = getEmDash(extra);

let input: MediaUsageRepairRequest;
if (args.scope === "collection") {
if (args.collection === undefined) {
return respondError(
"VALIDATION_ERROR",
"Collection is required when scope is collection",
);
}
input = { scope: "collection", collection: args.collection };
} else {
input = { scope: "all" };
}

try {
const { handleMediaUsageRepair } = await import("../api/handlers/media-usage.js");
return unwrap(await handleMediaUsageRepair(ec.db, input));
} catch (error) {
return respondHandlerError(error, "MEDIA_USAGE_REPAIR_ERROR");
}
},
);

// =====================================================================
// Search tool
// =====================================================================
Expand Down
Loading
Loading