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
20 changes: 10 additions & 10 deletions cli/src/commands/hits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
truncate,
} from "../lib/out.js";
import { parseIntervalMs, parseLimit } from "../lib/parse.js";
import { primeHitAnchor } from "../lib/hit-anchor.js";
import { resolveKeyRef } from "../lib/resolve.js";
import { withClient, type GlobalOpts } from "../lib/runner.js";

Expand Down Expand Up @@ -86,15 +87,14 @@ async function followHits(
const seen = new Set<string>();
const seenOrder: string[] = [];

// Anchor at start time. A one-millisecond overlap covers hits sharing a
// timestamp; the ID set prevents reprinting them on the next poll.
let watermarkMs = Date.now();
const initial = await client.listRecentHits({ key_id: id, limit: 500 });
for (const h of initial.data) {
if (new Date(h.occurred_at).getTime() < watermarkMs) {
seen.add(h.id);
seenOrder.push(h.id);
}
// A one-millisecond overlap covers hits sharing the anchor timestamp; IDs
// prevent reprinting them on the next poll.
const initial = await client.listRecentHits({ key_id: id, limit: 500, anchor: 1 });
const anchor = primeHitAnchor(initial);
let watermarkMs = anchor.watermarkMs;
for (const id of anchor.seenIds) {
seen.add(id);
seenOrder.push(id);
}

process.stderr.write(
Expand All @@ -111,7 +111,7 @@ async function followHits(
await new Promise((r) => setTimeout(r, intervalMs));
if (stop) break;
try {
const since = new Date(watermarkMs - 1).toISOString();
const since = new Date(Math.max(0, watermarkMs - 1)).toISOString();
const arrived: Hit[] = [];
let cursor: string | undefined;
do {
Expand Down
8 changes: 7 additions & 1 deletion cli/src/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export async function newCmd(
`unknown installer type "${effectiveOpts.install}". Available: ${ALL_INSTALL_TYPES.join(", ")}`,
);
}
if (effectiveOpts.install && !effectiveOpts.out && (effectiveOpts.idOnly || effectiveOpts.urlOnly)) {
fail("--install needs --out when used with --id-only or --url-only; otherwise the snippet has nowhere to go");
}

await withClient(effectiveOpts, async (client) => {
const key = await client.createKey({
Expand Down Expand Up @@ -326,7 +329,10 @@ export async function newCmd(
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`key ${key.id} was created, but setup did not finish: ${reason}. Resume with \`mantis show ${key.id}\`.`);
const recovery = effectiveOpts.install
? `Finish the installer with \`mantis install ${key.id} --type ${effectiveOpts.install}\`.`
: `Inspect the key with \`mantis show ${key.id}\`.`;
throw new Error(`key ${key.id} was created, but setup did not finish: ${reason}. ${recovery}`);
}
});
}
Expand Down
13 changes: 5 additions & 8 deletions cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MantisClient, RecentHit } from "../lib/api.js";
import { c, formatTime, isJsonMode } from "../lib/out.js";
import { parseIntervalMs } from "../lib/parse.js";
import { primeHitAnchor } from "../lib/hit-anchor.js";
import { resolveKeyRef } from "../lib/resolve.js";
import { withClient, type GlobalOpts } from "../lib/runner.js";

Expand All @@ -19,14 +20,14 @@ export async function watchCmd(opts: WatchOpts): Promise<void> {
);

const seen = new Set<string>();
let since = oneSecondAgo();

const prime = await client.listRecentHits({
...(keyId ? { key_id: keyId } : {}),
limit: 500,
anchor: 1,
});
for (const hit of prime.data) seen.add(hit.id);
since = backUpOneMs(newestOccurredAt(prime.data) ?? since);
const anchor = primeHitAnchor(prime);
for (const id of anchor.seenIds) seen.add(id);
let since = new Date(Math.max(0, anchor.watermarkMs - 1)).toISOString();

const tick = async () => {
try {
Expand Down Expand Up @@ -108,7 +109,3 @@ function backUpOneMs(iso: string): string {
if (Number.isNaN(t)) return iso;
return new Date(t - 1).toISOString();
}

function oneSecondAgo(): string {
return new Date(Date.now() - 1000).toISOString();
}
7 changes: 4 additions & 3 deletions cli/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export type AuditEvent = {
};

export type Page<T> = { data: T[]; next_cursor: string | null };
export type RecentHitsPage = Page<RecentHit> & { server_time?: string };

export type Health = {
status: "ok" | "degraded";
Expand Down Expand Up @@ -400,9 +401,9 @@ export class MantisClient {
}

listRecentHits(
query: { limit?: number; since?: string; since_id?: string; cursor?: string; key_id?: string } = {},
): Promise<Page<RecentHit>> {
return this.req<Page<RecentHit>>("/api/hits/recent", { query });
query: { limit?: number; since?: string; since_id?: string; cursor?: string; key_id?: string; anchor?: number } = {},
): Promise<RecentHitsPage> {
return this.req<RecentHitsPage>("/api/hits/recent", { query });
}

async fetchInstaller(
Expand Down
24 changes: 24 additions & 0 deletions cli/src/lib/hit-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RecentHitsPage } from "./api.js";

/** Seed a live stream from database time, never from the operator's clock. */
export function primeHitAnchor(page: RecentHitsPage): {
watermarkMs: number;
seenIds: string[];
} {
const serverMs = Date.parse(page.server_time ?? "");
if (Number.isFinite(serverMs)) {
return {
watermarkMs: serverMs,
seenIds: page.data
.filter((hit) => Date.parse(hit.occurred_at) < serverMs)
.map((hit) => hit.id),
};
}

// Older servers do not return server_time. Anchor at their newest hit, or
// the epoch for an empty feed, so local clock skew still cannot skip hits.
return {
watermarkMs: Math.max(0, ...page.data.map((hit) => Date.parse(hit.occurred_at))),
seenIds: page.data.map((hit) => hit.id),
};
}
34 changes: 34 additions & 0 deletions cli/tests/hit-anchor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { RecentHitsPage } from "../src/lib/api.js";
import { primeHitAnchor } from "../src/lib/hit-anchor.js";

function page(serverTime?: string): RecentHitsPage {
return {
data: [
{ id: "older", occurred_at: "2026-09-23T10:00:00.000Z" },
{ id: "during-prime", occurred_at: "2026-09-23T10:00:01.000Z" },
] as RecentHitsPage["data"],
next_cursor: null,
...(serverTime ? { server_time: serverTime } : {}),
};
}

describe("live hit anchor", () => {
it("uses server time and leaves hits arriving during priming unseen", () => {
expect(primeHitAnchor(page("2026-09-23T10:00:00.500Z"))).toEqual({
watermarkMs: Date.parse("2026-09-23T10:00:00.500Z"),
seenIds: ["older"],
});
});

it("uses existing hits when an older server omits the anchor", () => {
expect(primeHitAnchor(page())).toEqual({
watermarkMs: Date.parse("2026-09-23T10:00:01.000Z"),
seenIds: ["older", "during-prime"],
});
expect(primeHitAnchor({ data: [], next_cursor: null })).toEqual({
watermarkMs: 0,
seenIds: [],
});
});
});
41 changes: 41 additions & 0 deletions cli/tests/new-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock("../src/commands/install.js", () => ({
}));

import { newCmd } from "../src/commands/new.js";
import { runInstaller } from "../src/commands/install.js";

afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -38,4 +39,44 @@ describe("create and install", () => {

expect(output.join("")).toContain("curl https://mantis.example.com/c/abc123\n");
});

it("rejects output modes that would discard a generated snippet before creating a key", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exited"); });
vi.spyOn(process.stderr, "write").mockImplementation(() => true);

await expect(newCmd("first key", {
baseUrl: "https://mantis.example.com",
key: "test-key",
install: "shell",
idOnly: true,
})).rejects.toThrow("exited");
expect(fetch).not.toHaveBeenCalled();
expect(vi.mocked(process.stderr.write).mock.calls.join(" ")).toContain("--install needs --out");
});

it("gives a runnable recovery command after an installer fails", async () => {
vi.mocked(runInstaller).mockRejectedValueOnce(new Error("write failed"));
const errors: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
errors.push(String(chunk));
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exited"); });
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({
id: "00000000-0000-4000-8000-000000000001",
public_id: "abc123",
url: "https://mantis.example.com/c/abc123",
memo: "first key",
destinations: [],
}), { status: 201, headers: { "content-type": "application/json" } }));

await expect(newCmd("first key", {
baseUrl: "https://mantis.example.com",
key: "test-key",
install: "shell",
})).rejects.toThrow("exited");
expect(errors.join(" ")).toContain("mantis install 00000000-0000-4000-8000-000000000001 --type shell");
});
});
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
"drizzle-orm": "^0.45.2",
"jszip": "^3.10.1",
"nanoid": "^6.0.0",
"next": "^16.2.12",
"nodemailer": "^9.0.3",
"next": "^16.3.6",
"nodemailer": "^10.0.6",
"passkit-generator": "^3.5.7",
"pdf-lib": "^1.17.1",
"pino": "^10.3.1",
Expand All @@ -51,7 +51,7 @@
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.2",
"ua-parser-js": "^2.0.10",
"undici": "^8.9.0",
"undici": "^8.10.2",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
Loading
Loading