mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
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:
1 parent
f77823ad90
commit
420502eec1
90 files changed
+9172
-9718
No files matched your search
@@ -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();
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user