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
8 changes: 7 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ MANTIS_BASE_URL=https://mantis.example.com MANTIS_API_KEY=mantis_live_… mantis

The keychain layout means **the same API key works across profiles that share a base URL** — useful when you have prod + a prod-with-different-CF-Access-mode profile pointing at the same server.

`logout --profile <name>` and `profile rm <name> --yes` remove that profile.
Shared server credentials stay available until the last profile for the URL is
removed. To remove every server profile and its credentials, use `logout --all`.
Logout removes local CLI credentials; it does not revoke the server API key or
clear dashboard browser sessions.

### Where it's stored

- Config file: `$XDG_CONFIG_HOME/mantis/config.json` (or `~/.config/mantis/config.json`), mode `0600`
Expand Down Expand Up @@ -503,7 +509,7 @@ Remove-StoredCredential -Target "mantis-cli/https://mantis.example.com"
| Item | Owner | Where |
|---|---|---|
| Cloudflare Access SSO JWTs | `cloudflared` binary | `~/.cloudflared/` — short-lived (24h default); mantis shells out to `cloudflared access token` to read |
| Browser cookies for the dashboard | Your browser | Per-profile cookie store; cleared by `mantis logout` only on the server side |
| Browser cookies for the dashboard | Your browser | Per-profile cookie store; use the dashboard's sign-out action to clear its session |
| `npx wrangler dev` `MANTIS_EDGE_KEY` | `wrangler` | `mantis-edge/.dev.vars` — plaintext, gitignored; only present on dev machines |
| Git config / SSH keys | git / OpenSSH | Untouched |

Expand Down
128 changes: 80 additions & 48 deletions cli/src/commands/bulk-create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { writeFileSync } from "node:fs";
import { readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { appendFileSync, closeSync, fsyncSync, openSync } from "node:fs";
import { mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import type {
Key,
KeyWithDestinationResults,
Expand Down Expand Up @@ -90,68 +91,93 @@ export async function bulkCreateCmd(opts: BulkCreateOpts): Promise<void> {
}

await withClient(opts, async (client) => {
// Verify the requested output before sending a non-idempotent POST. Append
// mode leaves an existing result file intact until the completed write.
const outPath = resolve(opts.out!);
const output = await open(outPath, "a");
await output.close();
const recoveryDir = await mkdtemp(join(tmpdir(), "mantis-bulk-recovery-"));
const recoveryPath = join(recoveryDir, "completed.csv");
const journal = openSync(recoveryPath, "wx", 0o600);
appendFileSync(journal, writeCsv(loaded.outputHeaders, []));
fsyncSync(journal);
process.stderr.write(`Recovery CSV: ${recoveryPath} (completed mappings until output is saved).\n`);
const total = loaded.rows.length;
const onProgress = makeProgressReporter(total);

// Results land here as they complete (out of order under concurrency). The
// SIGINT handler reads it so an interrupt still flushes a valid id↔URL
// mapping — without it, keys created server-side would be unrecoverable
// locally, and a re-run would duplicate them (createKey has no idempotency
// key). Rows that never ran are marked so the operator sees what to retry.
// Each completed row is synced independently of the requested output path.
// A failed final write or interrupt can therefore retain confirmed mappings.
const sink: (RowResult | undefined)[] = new Array(total);
const finalize = (uncreatedNote: string): RowResult[] =>
loaded.rows.map((row, i) => sink[i] ?? rowError(row, uncreatedNote));

let interrupted = false;
let journalError: unknown;
const shouldStop = () => interrupted || journalError !== undefined;
const record = (result: RowResult) => {
try {
appendFileSync(journal, writeCsvRow(loaded.outputHeaders, result.row) + "\n");
fsyncSync(journal);
} catch (err) {
journalError ??= err;
}
onProgress?.(result);
};
const onSigint = () => {
if (interrupted) return;
interrupted = true;
const results = finalize("interrupted before creation");
const outPath = resolve(opts.out!);
try {
writeFileSync(
outPath,
writeCsv(loaded.outputHeaders, results.map((r) => r.row)),
"utf8",
);
} catch {
/* best effort on the way out */
}
const created = results.filter((r) => r.created).length;
process.stderr.write(
`\n${c.yellow("interrupted")} — wrote ${created}/${total} created so far to ${outPath}. ` +
`Those keys exist on the server; a re-run will create duplicates.\n`,
`\n${c.yellow("stopping")} — waiting for in-flight requests; completed mappings are saved at ${recoveryPath}.\n`,
);
process.exit(130);
};
process.on("SIGINT", onSigint);
try {
if (opts.failFast) {
await createSequentially(
client,
loaded.rows,
opts,
globalDestinations,
sink,
onProgress,
);
} else {
await mapLimit(
loaded.rows,
concurrency,
(row) => createOne(client, row, opts, globalDestinations),
sink,
onProgress,
);
try {
if (opts.failFast) {
await createSequentially(
client,
loaded.rows,
opts,
globalDestinations,
sink,
record,
shouldStop,
);
} else {
await mapLimit(
loaded.rows,
concurrency,
(row) => createOne(client, row, opts, globalDestinations),
sink,
record,
shouldStop,
);
}
} finally {
closeSync(journal);
}

const results = finalize(interrupted ? "interrupted before creation; no request sent" : "not created");
if (journalError !== undefined) {
// If even the recovery destination failed, retain the full completed
// mappings in the command log as the last available recovery surface.
process.stderr.write("Completed mappings (CSV):\n" + writeCsv(loaded.outputHeaders, results.filter((r) => r.created).map((r) => r.row)));
throw new Error(`stopped because recovery CSV could not be saved. Confirmed mappings are printed above; do not re-run the original CSV. ${String(journalError)}`);
}
try {
await writeResults(outPath, loaded.outputHeaders, results);
} catch (err) {
throw new Error(`keys may already exist on the server. Completed mappings are saved at ${recoveryPath}; do not re-run the original CSV. Could not write ${outPath}: ${err instanceof Error ? err.message : String(err)}`);
}
await rm(recoveryDir, { recursive: true, force: true });
emitSummary(opts.out!, results, false);
if (interrupted) {
process.stderr.write(`Stopped; confirmed mappings are saved at ${outPath}. Retry only rows marked interrupted before creation. Requests that failed without returning a key may already have completed; inspect the server before retrying them.\n`);
process.exitCode = 130;
} else if (results.some((result) => result.failed)) process.exitCode = 1;
} finally {
process.removeListener("SIGINT", onSigint);
}

const results = finalize("not created");
await writeResults(opts.out!, loaded.outputHeaders, results);
emitSummary(opts.out!, results, false);
if (results.some((result) => result.failed)) process.exitCode = 1;
});
}

Expand Down Expand Up @@ -298,8 +324,10 @@ async function createSequentially(
globalDestinations: DestinationInput[],
sink: (RowResult | undefined)[],
onProgress?: (result: RowResult) => void,
shouldStop: () => boolean = () => false,
): Promise<void> {
for (let i = 0; i < rows.length; i++) {
if (shouldStop()) return;
const result = await createOne(client, rows[i]!, opts, globalDestinations);
sink[i] = result;
onProgress?.(result);
Expand Down Expand Up @@ -623,12 +651,14 @@ async function mapLimit<T, R>(
fn: (item: T, index: number) => Promise<R>,
sink: Array<R | undefined>,
onProgress?: (result: R) => void,
shouldStop: () => boolean = () => false,
): Promise<void> {
let next = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
for (;;) {
if (shouldStop()) return;
const index = next;
next += 1;
if (index >= items.length) return;
Expand Down Expand Up @@ -713,13 +743,15 @@ function parseCsv(raw: string): string[][] {
function writeCsv(headers: string[], rows: CsvRecord[]): string {
const lines = [
headers.map(quoteCsvField).join(","),
...rows.map((row) =>
headers.map((header) => quoteCsvField(row[header] ?? "")).join(","),
),
...rows.map((row) => writeCsvRow(headers, row)),
];
return lines.join("\n") + "\n";
}

function writeCsvRow(headers: string[], row: CsvRecord): string {
return headers.map((header) => quoteCsvField(row[header] ?? "")).join(",");
}

// Cell starts that Excel/Sheets/Numbers evaluate as a formula. Prefix `'`
// so the cell renders as text instead — OWASP CSV-injection mitigation.
const FORMULA_PREFIX = /^[=+\-@\t\r]/;
Expand Down
74 changes: 45 additions & 29 deletions cli/src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export async function deviceNewCmd(opts: DeviceNewOpts): Promise<void> {
const profile = resolveProfile(profiles, opts.os, Boolean(opts.install));
const device = resolveDeviceName(opts);
const vectors = resolveVectors(profile, opts);
if (opts.install && !opts.dryRun) assertBundleInstallableHere(profile.os);

if (opts.dryRun) {
emit(
Expand All @@ -60,37 +61,52 @@ export async function deviceNewCmd(opts: DeviceNewOpts): Promise<void> {
// returns its existing keys instead of minting a duplicate set.
const minted: Array<{ id: string; slug: string; memo: string; url: string }> =
[];
for (const v of vectors) {
const memo = `${device} — ${v.label}`;
const key = await client.createKey({
memo,
external_id: externalId(device, profile.os, v.slug),
response_kind: v.response_kind,
dedupe_window_seconds: v.dedupe_window_seconds,
});
minted.push({ id: key.id, slug: v.slug, memo, url: key.url });
}

let bundlePath: string | null = null;
if (opts.bundle) {
const { data } = await client.downloadDeviceBundle({
device,
os: profile.os,
vectors: minted.map((m) => ({ id: m.id, slug: m.slug })),
});
bundlePath = resolve(opts.bundle);
await mkdir(dirname(bundlePath), { recursive: true });
await writeFile(bundlePath, data);
}

let installed = false;
if (opts.install) {
installed = await runLocalInstall(client, {
device,
os: profile.os,
vectors: minted.map((m) => ({ id: m.id, slug: m.slug })),
assumeYes: Boolean(opts.yes),
});
try {
for (const v of vectors) {
const memo = `${device} — ${v.label}`;
const key = await client.createKey({
memo,
external_id: externalId(device, profile.os, v.slug),
response_kind: v.response_kind,
dedupe_window_seconds: v.dedupe_window_seconds,
});
minted.push({ id: key.id, slug: v.slug, memo, url: key.url });
}

if (opts.bundle) {
const { data } = await client.downloadDeviceBundle({
device,
os: profile.os,
vectors: minted.map((m) => ({ id: m.id, slug: m.slug })),
});
bundlePath = resolve(opts.bundle);
await mkdir(dirname(bundlePath), { recursive: true });
await writeFile(bundlePath, data);
}

if (opts.install) {
installed = await runLocalInstall(client, {
device,
os: profile.os,
vectors: minted.map((m) => ({ id: m.id, slug: m.slug })),
assumeYes: Boolean(opts.yes),
});
}
} catch (err) {
const confirmed = minted.map((m) => ` ${m.slug}: ${m.id} ${m.url}`).join("\n");
const quote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
const target = client.profile ? `--profile ${quote(client.profile)}` : `--base-url ${quote(client.baseUrl)}`;
const resume = `mantis ${target} device new --name ${quote(device)} --os ${profile.os} --vectors ${quote(vectors.map((v) => v.slug).join(","))}` +
(opts.bundle ? ` --bundle ${quote(opts.bundle)}` : "") +
(opts.install ? " --install" : "");
const reason = err instanceof Error ? err.message : String(err);
throw new Error(
`device setup did not finish: ${reason}. ${minted.length} key(s) confirmed on the server` +
(confirmed ? `:\n${confirmed}` : ".") +
`\nResume with ${resume}, using the same authentication flags or environment. The same device/vector identities reuse existing keys, including a request that completed without returning its response.`,
);
}

emit(
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export async function listCmd(opts: ListOpts): Promise<void> {
}
await withClient(opts, async (client) => {
const limit = parseLimit(opts.limit);
const items = await collect(client.listKeys.bind(client), opts.all ? 1000 : limit);
const items = await collect(client.listKeys.bind(client), opts.all ? Infinity : limit);
emit(
() => {
if (items.length === 0) {
Expand Down
24 changes: 21 additions & 3 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DEFAULT_PROFILE,
getCurrentProfileName,
getProfile,
resolveCloudflareAuth,
setKey,
setProfile,
useProfile,
Expand Down Expand Up @@ -78,7 +79,14 @@ export async function loginCmd(opts: {
);
}

const client = new MantisClient({ baseUrl: url, key });
// Access credentials belong to the stored server, not the profile name.
// Changing --url must not forward the old server's JWT to another host.
const accessProfile = existing && sameServerUrl(existing.baseUrl, url) ? existing : null;
const client = new MantisClient({
baseUrl: url,
key,
cloudflare: resolveCloudflareAuth(url, accessProfile),
});
try {
await client.ping();
} catch (err) {
Expand All @@ -91,8 +99,8 @@ export async function loginCmd(opts: {
await setProfile(profileName, {
baseUrl: url,
keyPrefix: key.slice(0, 18),
cloudflareAccessAppUrl: existing?.cloudflareAccessAppUrl,
cloudflareAccessMode: existing?.cloudflareAccessMode,
cloudflareAccessAppUrl: accessProfile?.cloudflareAccessAppUrl,
cloudflareAccessMode: accessProfile?.cloudflareAccessMode,
edgeWorkerUrl: existing?.edgeWorkerUrl,
});
if (!opts.noSwitch) {
Expand All @@ -109,3 +117,13 @@ export async function loginCmd(opts: {
rl?.close();
}
}

function sameServerUrl(left: string, right: string): boolean {
try {
const a = new URL(left);
const b = new URL(right);
return a.origin === b.origin && a.pathname.replace(/\/+$/, "") === b.pathname.replace(/\/+$/, "");
} catch {
return false;
}
}
6 changes: 1 addition & 5 deletions cli/src/commands/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,12 @@ export async function logoutCmd(opts: {

const result = await removeProfile(target);
if (!result.removed) return fail(`profile '${target}' not found`);
if (result.baseUrl) {
deleteKey(result.baseUrl);
deleteCloudflareServiceAuth(result.baseUrl);
}
const tail = result.wasCurrent
? result.newCurrent
? c.dim(` (current → ${result.newCurrent})`)
: ""
: "";
process.stderr.write(
`${c.green("✓")} logged out of profile ${c.bold(target)}${tail}\n`,
`${c.green("✓")} removed profile ${c.bold(target)}${tail}${result.credentialsRetained ? c.dim(" (shared server credentials retained)") : ""}\n`,
);
}
Loading
Loading