feat: ship phase 1 surface area (v0.2.0)

- primora CLI + @primora/mcp server over generated client
- auth users admin panel, mobile bottom nav
- release workflow: GHCR images, compiled binaries, npm tarballs
- fix gzip Content-Length truncation, nginx upstream re-resolution

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-16 14:04:43 +02:00
1 parent f77823ad90
commit 420502eec1
90 files changed
+9172 -9718

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import type { AuditLog } from "@primora/api-client";
import { ProjectsService } from "@primora/api-client";
import pc from "picocolors";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { formatDate, isJson, printJson, printTable } from "../out.js";
interface AuditOptions {
project?: string;
q?: string;
action?: string;
limit?: string;
offset?: string;
follow?: boolean;
interval?: string;
json?: boolean;
}
function row(log: AuditLog): string[] {
return [
formatDate(log.created_at),
log.action,
`${log.resource_type} ${log.resource_id}`,
log.request_id,
];
}
export async function cmdAuditList(options: AuditOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const query = {
projectId,
q: options.q || undefined,
action: options.action || undefined,
limit: options.limit ? Number(options.limit) : 50,
offset: options.offset ? Number(options.offset) : undefined,
};
const page = await ProjectsService.listAuditLogs(query);
if (isJson(options)) {
printJson(page);
} else {
printTable(["TIME", "ACTION", "RESOURCE", "REQUEST"], page.items.map(row));
}
if (!options.follow) return;
// tail mode: poll and print newly seen ids
const seen = new Set(page.items.map((i) => i.id));
const interval = Math.max(1, Number(options.interval ?? 2)) * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, interval));
const next = await ProjectsService.listAuditLogs({ ...query, offset: undefined });
const fresh = next.items.filter((i) => !seen.has(i.id));
for (const id of fresh.map((i) => i.id)) seen.add(id);
for (const log of fresh.reverse()) {
if (isJson(options)) {
process.stdout.write(JSON.stringify(log) + "\n");
} else {
const [time, action, resource, request] = row(log);
process.stdout.write(`${pc.dim(time)} ${action} ${resource} ${pc.dim(request)}\n`);
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import * as p from "@clack/prompts";
import { PlatformService } from "@primora/api-client";
import { loadConfig, saveConfig, configFilePath } from "../config.js";
import { configureClient, CliError } from "../http.js";
import { dim, fail, isJson, printJson, printTable, success } from "../out.js";
interface LoginOptions {
url?: string;
email?: string;
password?: string;
apiKey?: string;
}
function cancel(): never {
p.cancel("Cancelled.");
process.exit(0);
}
export async function cmdLogin(options: LoginOptions): Promise<void> {
const cfg = loadConfig();
const interactive = process.stdin.isTTY;
let baseUrl = options.url ?? process.env.PRIMORA_BASE_URL;
if (!baseUrl) {
if (!interactive) throw new CliError("--url is required in non-interactive mode.");
const entered = await p.text({
message: "Primora URL",
initialValue: cfg.baseUrl,
validate: (v) => (/^https?:\/\/.+/.test(v ?? "") ? undefined : "Must be a http(s) URL"),
});
if (p.isCancel(entered)) cancel();
baseUrl = entered;
}
cfg.baseUrl = baseUrl.replace(/\/+$/, "");
if (options.apiKey) {
if (!/^\w+_\w+_.+/.test(options.apiKey)) {
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
}
cfg.auth = { type: "apiKey", key: options.apiKey };
saveConfig(cfg);
success(`Saved API key credentials to ${configFilePath()}`);
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
return;
}
let email = options.email;
let password = options.password;
if (!email || !password) {
if (!interactive) {
throw new CliError("--email and --password are required in non-interactive mode.");
}
if (!email) {
const entered = await p.text({ message: "Email" });
if (p.isCancel(entered)) cancel();
email = entered;
}
if (!password) {
const entered = await p.password({ message: "Password" });
if (p.isCancel(entered)) cancel();
password = entered;
}
}
const res = await fetch(`${cfg.baseUrl}/auth/sign-in/email`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
origin: cfg.baseUrl,
},
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = (await res.json().catch(() => undefined)) as { message?: string } | undefined;
throw new CliError(body?.message ?? `Sign-in failed (${res.status}).`);
}
const sessionCookie = res.headers
.getSetCookie()
.map((c) => c.split(";")[0])
.find((c) => c.includes("session_token="));
if (!sessionCookie) {
throw new CliError("Auth service did not return a session cookie.");
}
const eq = sessionCookie.indexOf("=");
cfg.auth = {
type: "session",
cookieName: sessionCookie.slice(0, eq),
sessionToken: sessionCookie.slice(eq + 1),
};
saveConfig(cfg);
configureClient(cfg);
const me = await PlatformService.getMe();
success(`Signed in as ${me.user.email}`);
dim(`Config written to ${configFilePath()}`);
}
export async function cmdLogout(): Promise<void> {
const cfg = loadConfig();
delete cfg.auth;
saveConfig(cfg);
success("Signed out — credentials removed.");
}
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig();
configureClient(cfg);
const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me);
return;
}
printTable(["USER", "EMAIL", "VERIFIED"], [
[me.user.name || "—", me.user.email, me.user.emailVerified ? "yes" : "no"],
]);
process.stdout.write("\n");
printTable(
["ORGANIZATION", "ROLE", "PROJECTS"],
me.organizations.map((o) => [
o.name,
o.membershipRole,
o.projects.map((pr) => pr.slug).join(", ") || "—",
]),
);
dim(`\n${cfg.baseUrl} · auth: ${cfg.auth?.type ?? "none"}`);
}
+56
View File
@@ -0,0 +1,56 @@
import { CreateBucketRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js";
export async function cmdBucketsList(options: {
project?: string;
q?: string;
json?: boolean;
}): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const { items } = await StorageService.listBuckets({
projectId,
q: options.q || undefined,
});
if (isJson(options)) {
printJson(items);
return;
}
printTable(
["SLUG", "NAME", "VISIBILITY", "ID"],
items.map((b) => [b.slug, b.name, b.visibility, b.id]),
);
}
export async function cmdBucketsCreate(
name: string,
options: { project?: string; slug?: string; public?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const slug = options.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const bucket = await StorageService.createBucket({
projectId,
requestBody: {
name,
slug,
visibility: options.public
? CreateBucketRequest.visibility.PUBLIC
: CreateBucketRequest.visibility.PRIVATE,
},
});
if (isJson(options)) {
printJson(bucket);
return;
}
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
}
+98
View File
@@ -0,0 +1,98 @@
import * as p from "@clack/prompts";
import { PlatformService } from "@primora/api-client";
import { loadConfig, saveConfig } from "../config.js";
import { configureClient, requireAuth } from "../http.js";
import { dim, isJson, printJson, success } from "../out.js";
interface UseOptions {
org?: string;
project?: string;
}
/** Match an id or slug against a {id, slug, name} record. */
function matchRef<T extends { id: string; slug: string; name: string }>(
items: T[],
ref: string,
): T | undefined {
return items.find((i) => i.id === ref || i.slug === ref || i.name === ref);
}
export async function cmdUse(options: UseOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const me = await PlatformService.getMe();
const orgs = me.organizations;
if (orgs.length === 0) {
dim("No organizations yet — create one with `primora orgs create <name>`.");
return;
}
if (options.org) {
const org = matchRef(orgs, options.org);
if (!org) throw new Error(`Organization "${options.org}" not found.`);
cfg.organizationId = org.id;
if (!org.projects.some((pr) => pr.id === cfg.projectId)) delete cfg.projectId;
} else if (process.stdin.isTTY) {
const picked = await p.select({
message: "Organization",
options: orgs.map((o) => ({ value: o.id, label: o.name, hint: o.slug })),
initialValue: cfg.organizationId,
});
if (p.isCancel(picked)) return;
cfg.organizationId = picked;
if (!orgs.find((o) => o.id === picked)?.projects.some((pr) => pr.id === cfg.projectId)) {
delete cfg.projectId;
}
}
const org = orgs.find((o) => o.id === cfg.organizationId);
if (!org) throw new Error("No organization selected.");
if (options.project) {
const project = matchRef(org.projects, options.project);
if (!project) throw new Error(`Project "${options.project}" not found in ${org.name}.`);
cfg.projectId = project.id;
} else if (process.stdin.isTTY && org.projects.length > 0) {
const picked = await p.select({
message: "Project",
options: org.projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.slug })),
initialValue: cfg.projectId,
});
if (p.isCancel(picked)) {
saveConfig(cfg);
return;
}
cfg.projectId = picked;
}
saveConfig(cfg);
success(`Context → ${org.slug}${cfg.projectId ? ` / ${org.projects.find((pr) => pr.id === cfg.projectId)?.slug ?? cfg.projectId}` : ""}`);
}
export async function cmdContext(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const me = await PlatformService.getMe();
const org = me.organizations.find((o) => o.id === cfg.organizationId);
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
if (isJson(options)) {
printJson({
baseUrl: cfg.baseUrl,
auth: cfg.auth?.type ?? null,
organization: org ?? null,
project: project ?? null,
});
return;
}
dim(`url ${cfg.baseUrl}`);
dim(`auth ${cfg.auth?.type ?? "none"}`);
process.stdout.write(`org ${org ? `${org.name} (${org.slug})` : "—"}\n`);
process.stdout.write(`project ${project ? `${project.name} (${project.slug})` : "—"}\n`);
}
+65
View File
@@ -0,0 +1,65 @@
import * as p from "@clack/prompts";
import { ProjectsService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { dim, formatDate, isJson, printJson, printTable, success } from "../out.js";
export async function cmdKeysList(options: { project?: string; json?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const { items } = await ProjectsService.listApiKeys({ projectId });
if (isJson(options)) {
printJson(items);
return;
}
printTable(
["ID", "PREFIX", "NAME", "LAST USED", "STATUS"],
items.map((k) => [
k.id,
k.prefix,
k.name,
formatDate(k.last_used_at),
k.revoked_at ? "revoked" : "active",
]),
);
}
export async function cmdKeysCreate(
name: string,
options: { project?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const key = await ProjectsService.createApiKey({ projectId, requestBody: { name } });
if (isJson(options)) {
printJson(key);
return;
}
success(`API key "${key.name}" created (${key.prefix})`);
process.stdout.write(`${key.secret}\n`);
dim("Store it now — the secret is shown once.");
}
export async function cmdKeysRevoke(
apiKeyId: string,
options: { project?: string; yes?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
if (!options.yes && process.stdin.isTTY) {
const confirmed = await p.confirm({ message: `Revoke API key ${apiKeyId}?` });
if (p.isCancel(confirmed) || !confirmed) return;
}
await ProjectsService.revokeApiKey({ projectId, apiKeyId });
success(`Revoked ${apiKeyId}`);
}
+135
View File
@@ -0,0 +1,135 @@
import { statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import {
apiFetch,
CliError,
configureClient,
requireAuth,
requireProject,
resolveBucketId,
} from "../http.js";
import { formatBytes, formatDate, isJson, printJson, printTable, success } from "../out.js";
const MIME_BY_EXT: Record<string, string> = {
".json": "application/json",
".txt": "text/plain",
".md": "text/markdown",
".csv": "text/csv",
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".ts": "text/typescript",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".pdf": "application/pdf",
".zip": "application/zip",
".yaml": "application/yaml",
".yml": "application/yaml",
".xml": "application/xml",
};
function guessType(path: string): string {
const dot = path.lastIndexOf(".");
if (dot < 0) return "application/octet-stream";
return MIME_BY_EXT[path.slice(dot).toLowerCase()] ?? "application/octet-stream";
}
async function bucketIdFor(cfg: ReturnType<typeof loadConfig>, bucketRef: string, projectOverride?: string) {
const projectId = requireProject(cfg, projectOverride);
return resolveBucketId(projectId, bucketRef);
}
export async function cmdObjectsList(
bucketRef: string,
options: { project?: string; q?: string; limit?: string; offset?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const page = await StorageService.listBucketObjects({
bucketId,
q: options.q || undefined,
limit: options.limit ? Number(options.limit) : 50,
offset: options.offset ? Number(options.offset) : undefined,
});
if (isJson(options)) {
printJson(page);
return;
}
printTable(
["KEY", "TYPE", "SIZE", "CREATED"],
page.items.map((o) => [o.object_key, o.content_type, formatBytes(o.size_bytes), formatDate(o.created_at)]),
);
}
export async function cmdObjectsUpload(
bucketRef: string,
file: string,
options: { project?: string; key?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const stat = statSync(file); // throws ENOENT with a clear message
if (!stat.isFile()) throw new CliError(`Not a file: ${file}`);
const objectKey = options.key ?? basename(file);
const buffer = await readFile(file);
const blob = new Blob([buffer], { type: guessType(file) });
const object = await StorageService.uploadBucketObject({
bucketId,
formData: { objectKey, file: blob },
});
if (isJson(options)) {
printJson(object);
return;
}
success(`Uploaded ${object.object_key} (${formatBytes(object.size_bytes)})`);
}
export async function cmdObjectsDownload(
bucketRef: string,
objectKey: string,
options: { project?: string; out?: string },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const res = await apiFetch(
cfg,
`/buckets/${bucketId}/objects/${encodeURIComponent(objectKey)}`,
);
const outPath = options.out ?? basename(objectKey);
const bytes = Buffer.from(await res.arrayBuffer());
await writeFile(outPath, bytes);
success(`Downloaded ${objectKey} → ${outPath} (${formatBytes(bytes.length)})`);
}
export async function cmdObjectsRemove(
bucketRef: string,
objectKey: string,
options: { project?: string },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
await StorageService.deleteBucketObject({ bucketId, objectKey });
success(`Deleted ${objectKey}`);
}
+40
View File
@@ -0,0 +1,40 @@
import { OrganizationsService, PlatformService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js";
export async function cmdOrgsList(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me.organizations);
return;
}
printTable(
["ID", "SLUG", "NAME", "ROLE"],
me.organizations.map((o) => [o.id, o.slug, o.name, o.membershipRole]),
);
}
export async function cmdOrgsCreate(
name: string,
options: { slug?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const slug = options.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const org = await OrganizationsService.createOrganization({
requestBody: { name, slug },
});
if (isJson(options)) {
printJson(org);
return;
}
success(`Organization "${org.name}" created (${org.slug})`);
}
+52
View File
@@ -0,0 +1,52 @@
import { ProjectsService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireOrg } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js";
interface ListOptions {
org?: string;
q?: string;
json?: boolean;
}
export async function cmdProjectsList(options: ListOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const organizationId = requireOrg(cfg, options.org);
const { items } = await ProjectsService.listProjects({
organizationId,
q: options.q || undefined,
});
if (isJson(options)) {
printJson(items);
return;
}
printTable(
["ID", "SLUG", "NAME", "ROLE"],
items.map((pr) => [pr.id, pr.slug, pr.name, pr.membership_role ?? "—"]),
);
}
export async function cmdProjectsCreate(
name: string,
options: { org?: string; slug?: string; description?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const organizationId = requireOrg(cfg, options.org);
const slug = options.slug ?? name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const project = await ProjectsService.createProject({
organizationId,
requestBody: { name, slug, description: options.description },
});
if (isJson(options)) {
printJson(project);
return;
}
success(`Project "${project.name}" created (${project.slug})`);
}