feat(cli+broker): structured argument validation, msg-status prefixes (v1.9.3)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds apps/cli/src/cli/validators.ts — a small module of shape
validators (pubkey, pubkey prefix, message id, mesh slug) that return
discriminated results so callers can distinguish "shape is wrong"
(INVALID_ARGS exit) from "value is well-shaped, lookup failed"
(NOT_FOUND exit). Includes renderValidationError() for a consistent
three-tier error contract: what's wrong, what would be valid, closest
valid alternative.

First adopter is `claudemesh msg-status`:
- Validates id locally before opening WS — typos return immediately.
- Accepts 8-32 char prefixes (full ids are 32). Pastes that get
  copy-truncated by the terminal still work.
- Distinct error messages for malformed input vs not-in-queue vs
  ambiguous prefix; --json emits the structured shape.

Broker side: WS message_status handler validates idStr is 8-32
base62 before querying. Prefix lookups use LIKE 'prefix%' scoped to
the caller's mesh (no cross-mesh leak). Returns ambiguous_prefix
when more than one match.

Establishes the canonical pattern; rolling out to send / grant /
revoke / topic post --reply-to in subsequent patches.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 22:40:45 +01:00
parent 82ee89d0dc
commit 80755dbf9b
4 changed files with 300 additions and 17 deletions

View File

@@ -20,6 +20,7 @@ import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
import { validateMessageId, renderValidationError } from "~/cli/validators.js";
type StateFlags = { mesh?: string; json?: boolean };
type PeerStatus = "idle" | "working" | "dnd";
@@ -186,15 +187,50 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi
// --- msg-status ---
export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> {
if (!id) {
render.err("Usage: claudemesh msg-status <message-id>");
// Validate input shape *before* we open a WS connection, so a typo
// returns a structured error instead of "not found or timed out".
const v = validateMessageId(id);
if (!v.ok) {
if (opts.json) {
console.log(
JSON.stringify({
ok: false,
error: "invalid_argument",
field: "messageId",
code: v.code,
reason: v.reason,
expected: v.expected,
}),
);
} else {
renderValidationError({
verb: "msg-status",
input: id ?? "(missing)",
result: v,
});
}
return EXIT.INVALID_ARGS;
}
const lookupId = v.value.value;
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const result = await client.messageStatus(id);
const result = await client.messageStatus(lookupId);
if (!result) {
if (opts.json) console.log(JSON.stringify({ id, found: false }));
else render.err(`Message ${id} not found or timed out.`);
if (opts.json) {
console.log(
JSON.stringify({
ok: false,
error: "not_found",
id: lookupId,
isPrefix: v.value.isPrefix,
}),
);
} else {
const hint = v.value.isPrefix
? ` no message id starts with ${dim("\"" + lookupId + "\"")} in this mesh.\n try: claudemesh msg-status <full-32-char-id>`
: ` message ${dim(lookupId.slice(0, 12) + "…")} not in queue (already drained, expired, or never sent in this mesh).`;
render.err(`message not found`);
process.stderr.write(hint + "\n");
}
return EXIT.NOT_FOUND;
}
if (opts.json) {