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

+34
View File
@@ -0,0 +1,34 @@
{
"name": "@primora/cli",
"version": "0.2.0",
"description": "Primora command-line client",
"type": "module",
"bin": {
"primora": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
"dev": "npm run build && node dist/cli.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@clack/prompts": "^1.8.1",
"cac": "^7.0.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@primora/api-client": "0.2.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=20"
}
}
+139
View File
@@ -0,0 +1,139 @@
import { cac } from "cac";
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import {
cmdObjectsDownload,
cmdObjectsList,
cmdObjectsRemove,
cmdObjectsUpload,
} from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
import { fail } from "./out.js";
const cli = cac("primora");
cli.option("--json", "Output raw JSON");
cli
.command("login", "Sign in or store credentials")
.option("--url <url>", "Primora base URL (default http://localhost)")
.option("--email <email>", "Account email")
.option("--password <password>", "Account password")
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
.action(cmdLogin);
cli.command("logout", "Remove stored credentials").action(cmdLogout);
cli.command("whoami", "Show the current identity and memberships").action(cmdWhoami);
cli
.command("use", "Select organization and project context")
.option("--org <id|slug>", "Organization id or slug")
.option("--project <id|slug>", "Project id or slug")
.action(cmdUse);
cli.command("context", "Show the current context").action(cmdContext);
cli.command("orgs:list", "List organizations (`primora orgs list`)").action(cmdOrgsList);
cli
.command("orgs:create <name>", "Create an organization")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.action(cmdOrgsCreate);
cli
.command("projects:list", "List projects in the current org")
.option("--org <id>", "Organization override")
.option("--q <query>", "Search filter")
.action(cmdProjectsList);
cli
.command("projects:create <name>", "Create a project")
.option("--org <id>", "Organization override")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--description <text>", "Description")
.action(cmdProjectsCreate);
cli
.command("buckets:list", "List buckets in the current project")
.option("--project <id>", "Project override")
.option("--q <query>", "Search filter")
.action(cmdBucketsList);
cli
.command("buckets:create <name>", "Create a bucket")
.option("--project <id>", "Project override")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--public", "Public visibility (default private)")
.action(cmdBucketsCreate);
cli
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
.option("--project <id>", "Project override")
.option("--q <query>", "Search filter")
.option("--limit <n>", "Page size (default 50)")
.option("--offset <n>", "Page offset")
.action(cmdObjectsList);
cli
.command("objects:upload <bucket> <file>", "Upload a file to a bucket")
.option("--project <id>", "Project override")
.option("--key <key>", "Object key (default: file basename)")
.action(cmdObjectsUpload);
cli
.command("objects:download <bucket> <key>", "Download an object")
.option("--project <id>", "Project override")
.option("--out <path>", "Output path (default: key basename)")
.action(cmdObjectsDownload);
cli
.command("objects:rm <bucket> <key>", "Delete an object")
.option("--project <id>", "Project override")
.action(cmdObjectsRemove);
cli
.command("keys:list", "List API keys for the current project")
.option("--project <id>", "Project override")
.action(cmdKeysList);
cli
.command("keys:create <name>", "Create an API key (secret shown once)")
.option("--project <id>", "Project override")
.action(cmdKeysCreate);
cli
.command("keys:revoke <id>", "Revoke an API key")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdKeysRevoke);
cli
.command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override")
.option("--q <query>", "Search filter")
.option("--action <action>", "Filter by action (e.g. object.uploaded)")
.option("--limit <n>", "Page size (default 50)")
.option("--offset <n>", "Page offset")
.option("--follow", "Keep polling for new events (tail mode)")
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
.action(cmdAuditList);
cli.help();
cli.version("0.2.0");
// cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work.
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "audit"]);
const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
}
async function main() {
try {
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
await cli.runMatchedCommand();
} catch (error) {
fail(error);
}
}
main();
+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})`);
}
+46
View File
@@ -0,0 +1,46 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export type AuthConfig =
| { type: "session"; cookieName: string; sessionToken: string }
| { type: "apiKey"; key: string };
export interface CliConfig {
baseUrl: string;
auth?: AuthConfig;
organizationId?: string;
projectId?: string;
}
const configDir =
process.env.PRIMORA_CONFIG_DIR ??
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora");
const configPath = join(configDir, "config.json");
export function configFilePath(): string {
return configPath;
}
export function loadConfig(): CliConfig {
const cfg: CliConfig = { baseUrl: "http://localhost" };
try {
if (existsSync(configPath)) {
Object.assign(cfg, JSON.parse(readFileSync(configPath, "utf8")) as Partial<CliConfig>);
}
} catch {
// corrupt config — start from defaults rather than crashing every command
}
if (process.env.PRIMORA_BASE_URL) cfg.baseUrl = process.env.PRIMORA_BASE_URL;
if (process.env.PRIMORA_API_KEY) cfg.auth = { type: "apiKey", key: process.env.PRIMORA_API_KEY };
if (process.env.PRIMORA_ORG) cfg.organizationId = process.env.PRIMORA_ORG;
if (process.env.PRIMORA_PROJECT) cfg.projectId = process.env.PRIMORA_PROJECT;
cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
return cfg;
}
export function saveConfig(cfg: CliConfig): void {
mkdirSync(configDir, { recursive: true });
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
chmodSync(configPath, 0o600);
}
+110
View File
@@ -0,0 +1,110 @@
import { OpenAPI, StorageService } from "@primora/api-client";
import type { CliConfig } from "./config.js";
export class CliError extends Error {
constructor(
message: string,
readonly hint?: string,
) {
super(message);
}
}
async function mintSessionToken(cfg: CliConfig): Promise<string> {
if (cfg.auth?.type !== "session") throw new CliError("No session stored.");
const res = await fetch(`${cfg.baseUrl}/auth/token`, {
headers: {
accept: "application/json",
cookie: `${cfg.auth.cookieName}=${cfg.auth.sessionToken}`,
},
});
if (res.status === 401 || res.status === 403) {
throw new CliError("Session expired.", "Run `primora login` again.");
}
if (!res.ok) {
throw new CliError(`Failed to mint API token (${res.status}).`);
}
return ((await res.json()) as { token: string }).token;
}
/** Auth headers matching the backend's ResolveActor: Bearer JWT or X-API-Key. */
export async function authHeaders(cfg: CliConfig): Promise<Record<string, string>> {
if (cfg.auth?.type === "apiKey") return { "X-API-Key": cfg.auth.key };
if (cfg.auth?.type === "session") {
return { Authorization: `Bearer ${await mintSessionToken(cfg)}` };
}
return {};
}
/** Raw request against the API — for binary endpoints the generated client
* can't serve (it decodes every non-JSON response as text). */
export async function apiFetch(cfg: CliConfig, path: string): Promise<Response> {
const res = await fetch(`${cfg.baseUrl}/api/v1${path}`, {
headers: await authHeaders(cfg),
});
if (!res.ok) {
const body = (await res.json().catch(() => undefined)) as
| { error?: { message?: string } }
| undefined;
throw new CliError(
body?.error?.message ?? `Request failed (${res.status})`,
res.status === 401 ? "Check auth — run `primora login`." : undefined,
);
}
return res;
}
/** Wire the generated OpenAPI client to the saved config. */
export function configureClient(cfg: CliConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
OpenAPI.HEADERS = undefined;
OpenAPI.TOKEN = undefined;
if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
return;
}
if (cfg.auth?.type === "session") {
let cached: string | undefined;
OpenAPI.TOKEN = async () => {
cached ??= await mintSessionToken(cfg);
return cached;
};
}
}
export function requireAuth(cfg: CliConfig): void {
if (!cfg.auth) {
throw new CliError("Not authenticated.", "Run `primora login` or set PRIMORA_API_KEY.");
}
}
export function requireOrg(cfg: CliConfig, override?: string): string {
const id = override ?? cfg.organizationId;
if (!id) {
throw new CliError("No organization selected.", "Run `primora use --org <id>` or pass --org.");
}
return id;
}
export function requireProject(cfg: CliConfig, override?: string): string {
const id = override ?? cfg.projectId;
if (!id) {
throw new CliError("No project selected.", "Run `primora use --project <id>` or pass --project.");
}
return id;
}
/** Resolve a bucket reference that may be an id or a slug. */
export async function resolveBucketId(projectId: string, ref: string): Promise<string> {
const { items } = await StorageService.listBuckets({ projectId });
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
if (!match) {
throw new CliError(
`Bucket "${ref}" not found.`,
`Known buckets: ${items.map((b) => b.slug).join(", ") || "(none)"}`,
);
}
return match.id;
}
+66
View File
@@ -0,0 +1,66 @@
import pc from "picocolors";
export function isJson(options: { json?: boolean }): boolean {
return Boolean(options.json);
}
export function printJson(data: unknown): void {
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
}
export function success(message: string): void {
process.stdout.write(pc.green("✓ ") + message + "\n");
}
export function dim(message: string): void {
process.stdout.write(pc.dim(message) + "\n");
}
export function fail(error: unknown): never {
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
let message = err?.message ?? "unknown error";
// generated client throws ApiError with a parsed body
const body = err?.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) message = body.error.message;
if (err?.status === 401) message = `${message} (401 — check auth)`;
process.stderr.write(pc.red("error ") + message + "\n");
if (err?.hint) process.stderr.write(pc.dim(`hint ${err.hint}`) + "\n");
process.exit(1);
}
/** Minimal column printer — left-aligned, two-space gutter. */
export function printTable(headers: string[], rows: string[][]): void {
const all = headers.length ? [headers, ...rows] : rows;
const widths: number[] = [];
for (const row of all) {
row.forEach((cell, i) => {
widths[i] = Math.max(widths[i] ?? 0, cell.length);
});
}
const line = (row: string[]) =>
row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
if (headers.length) {
process.stdout.write(pc.dim(line(headers)) + "\n");
}
for (const row of rows) process.stdout.write(line(row) + "\n");
}
export function formatBytes(value: number | undefined): string {
if (value === undefined || !Number.isFinite(value) || value < 0) return "—";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = value;
let unit = -1;
do {
size /= 1024;
unit++;
} while (size >= 1024 && unit < units.length - 1);
return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function formatDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toISOString().replace("T", " ").slice(0, 16);
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}