Skip to content
Merged
4 changes: 2 additions & 2 deletions sdks/urbackend-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@urbackend/cli",
"version": "0.1.1",
"version": "0.2.0",
"description": "Official CLI for urBackend β€” manage projects, schemas, and more from your terminal",
"publishConfig": {
"access": "public"
Expand All @@ -17,7 +17,7 @@
"scripts": {
"build": "tsup --config tsup.config.ts",
"dev": "tsup --config tsup.config.ts --watch",
"lint": "eslint src",
"lint": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\""
},
"dependencies": {
Expand Down
89 changes: 89 additions & 0 deletions sdks/urbackend-cli/src/commands/generate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import fs from "node:fs";
import path from "node:path";
import { getLocalSchemas } from "../../core/workspace.js";
import { logger } from "../../core/logger.js";
import type { CollectionField } from "../../types/project.js";

function mapType(field: CollectionField, indent = " "): string {
switch (field.type) {
case "String":
return "string";
case "Number":
return "number";
case "Boolean":
return "boolean";
case "Date":
return "string | Date";
case "Ref":
return "string";
case "Array":
if (field.fields && field.fields.length > 0) {
// We only support arrays of objects for now, or array of singular types if it's a single field with empty key
if (field.fields.length === 1 && !field.fields[0].key) {
return `Array<${mapType(field.fields[0], indent)}>`;
}
return `Array<${parseFields(field.fields, indent + " ")}>`;
}
return "any[]";
case "Object":
if (field.fields && field.fields.length > 0) {
return parseFields(field.fields, indent + " ");
}
return "Record<string, any>";
default:
return "any";
}
}

function parseFields(fields: CollectionField[], indent: string): string {
let out = "{\n";
for (const f of fields) {
const isOptional = !f.required ? "?" : "";
const typeStr = mapType(f, indent);
out += `${indent} "${f.key}"${isOptional}: ${typeStr};\n`;
}
out += `${indent}}`;
return out;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export async function generateCommand(): Promise<void> {
const schemas = getLocalSchemas();

if (schemas.length === 0) {
logger.error("No schemas found in .ub/schemas/. Run 'ub pull' first.");
process.exitCode = 1;
return;
}

let dtsContent = `// Auto-generated by urBackend CLI\n// Do not edit this file directly\n\n`;
dtsContent += `export interface UrBackendTypes {\n`;

for (const { name, schema } of schemas) {
if (!schema.model || !Array.isArray(schema.model)) continue;

dtsContent += ` "${name}": {\n`;
dtsContent += ` _id: string;\n`;

for (const field of schema.model as CollectionField[]) {
const isOptional = !field.required ? "?" : "";
const typeStr = mapType(field, " ");
dtsContent += ` "${field.key}"${isOptional}: ${typeStr};\n`;
}

dtsContent += ` createdAt: string | Date;\n`;
dtsContent += ` updatedAt: string | Date;\n`;
dtsContent += ` };\n`;
}

dtsContent += `}\n`;

const outputPath = path.join(process.cwd(), "urbackend.d.ts");

try {
fs.writeFileSync(outputPath, dtsContent, "utf8");
logger.success(`Generated TypeScript definitions at ./urbackend.d.ts`);
} catch (error) {
logger.error(`Failed to write urbackend.d.ts: ${(error as Error).message}`);
process.exitCode = 1;
}
}
99 changes: 99 additions & 0 deletions sdks/urbackend-cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { listProjects } from "../../services/project.service.js";
import { getToken } from "../../core/config.js";
import { saveWorkspaceConfig, getWorkspaceDir } from "../../core/workspace.js";
import { prompt } from "../../utils/prompt.js";
import { APIError } from "../../core/errors.js";
import { logger } from "../../core/logger.js";

export async function initCommand(projectIdOrName?: string): Promise<void> {
const token = getToken();
if (!token) {
logger.error("You are not logged in. Run 'ub login' first.");
process.exitCode = 1;
return;
}

try {
const projects = await listProjects();

if (projects.length === 0) {
logger.info("No projects found. Create one at dashboard.urbackend.bitbros.in");
return;
}

let selectedId: string | undefined;
let selectedName: string | undefined;

if (projectIdOrName) {
const match = projects.find(
(p) =>
p._id === projectIdOrName ||
p.name.toLowerCase() === projectIdOrName.toLowerCase(),
);
if (!match) {
logger.error(`No project found matching "${projectIdOrName}".`);
logger.info("Run 'ub project list' to see available projects.");
process.exitCode = 1;
return;
}
selectedId = match._id;
selectedName = match.name;
} else {
console.log("\nAvailable projects:\n");
projects.forEach((p, i) => {
console.log(` [${i + 1}] ${p.name} (${p._id})`);
});
console.log();

const answer = await prompt("Select a project to link this directory to: ");
if (!/^\d+$/.test(answer)) {
logger.error("Invalid selection.");
process.exitCode = 1;
return;
}
const index = Number(answer) - 1;
if (isNaN(index) || index < 0 || index >= projects.length) {
logger.error("Invalid selection.");
process.exitCode = 1;
return;
}

selectedId = projects[index]._id;
selectedName = projects[index].name;
}

saveWorkspaceConfig({ projectId: selectedId, projectName: selectedName });

// Update .gitignore if it exists
const gitignorePath = path.join(process.cwd(), ".gitignore");
if (fs.existsSync(gitignorePath)) {
const gitignore = fs.readFileSync(gitignorePath, "utf8");
const lines = gitignore.split(/\r?\n/).map(line => line.trim());
if (!lines.includes(".ub") && !lines.includes("/.ub") && !lines.includes(".ub/")) {
fs.appendFileSync(gitignorePath, "\n# urBackend local workspace\n.ub\n", "utf8");
logger.info("Added .ub to .gitignore");
}
} else {
fs.writeFileSync(gitignorePath, "# urBackend local workspace\n.ub\n", "utf8");
logger.info("Created .gitignore and added .ub");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

logger.success(`Successfully initialized urBackend workspace in .ub/`);
logger.info(`Linked to project: ${selectedName} (${selectedId})`);
console.log(`\nNext, run 'ub pull' to download your schemas.`);
} catch (error) {
if (error instanceof APIError) {
if (error.status === 401) {
logger.error("Token is invalid or expired. Run 'ub login' to re-authenticate.");
} else {
logger.error(error.message);
}
process.exitCode = 1;
return;
}
logger.error("Unable to connect to the urBackend API.");
process.exitCode = 1;
}
}
76 changes: 76 additions & 0 deletions sdks/urbackend-cli/src/commands/pull/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { loadWorkspaceConfig, saveSchemaFile, clearSchemaFiles, isValidCollectionName } from "../../core/workspace.js";
import { getProject } from "../../services/project.service.js";
import { getToken } from "../../core/config.js";
import { APIError } from "../../core/errors.js";
import { logger } from "../../core/logger.js";
import { label } from "../../utils/format.js";

export async function pullCommand(): Promise<void> {
const token = getToken();
if (!token) {
logger.error("You are not logged in. Run 'ub login' first.");
process.exitCode = 1;
return;
}

const workspaceConfig = loadWorkspaceConfig();
if (!workspaceConfig || !workspaceConfig.projectId) {
logger.error(
"No project linked to this directory. Run 'ub init' to link a project first."
);
process.exitCode = 1;
return;
}

const { projectId, projectName } = workspaceConfig;
logger.info(`Fetching schemas for ${projectName ? projectName + " " : ""}(${projectId})...`);

try {
const project = await getProject(projectId);

if (!project.collections || project.collections.length === 0) {
logger.info("This project has no collections defined yet.");
return;
}

let count = 0;

// Validate all remote collection names up front before clearing local schemas
for (const collection of project.collections) {
if (!isValidCollectionName(collection.name)) {
throw new Error(`Invalid collection name received from remote: ${collection.name}`);
}
}

clearSchemaFiles();
for (const collection of project.collections) {
saveSchemaFile(collection.name, {
name: collection.name,
model: collection.model,
rls: collection.rls,
});
count++;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

logger.success(`Successfully pulled ${count} collection schema(s) into .ub/schemas/`);
console.log();
for (const collection of project.collections) {
console.log(` ${label("schema")} ${collection.name}.json`);
}
console.log(`\nNext, run 'ub generate' to create your TypeScript types.`);
} catch (error) {
if (error instanceof APIError) {
if (error.status === 401) {
logger.error("Token is invalid or expired. Run 'ub login' to re-authenticate.");
} else if (error.status === 403) {
logger.error("You do not have permission to access this project.");
} else {
logger.error(error.message);
}
process.exitCode = 1;
return;
}
logger.error("Unable to connect to the urBackend API.");
process.exitCode = 1;
}
}
98 changes: 98 additions & 0 deletions sdks/urbackend-cli/src/core/workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import fs from "node:fs";
import path from "node:path";
import type { WorkspaceConfig } from "../types/config.js";
import { logger } from "./logger.js";

const WORKSPACE_DIR = ".ub";
const CONFIG_FILE = "config.json";
const SCHEMAS_DIR = "schemas";

export function getWorkspaceDir(): string {
return path.join(process.cwd(), WORKSPACE_DIR);
}

export function getWorkspaceConfigPath(): string {
return path.join(getWorkspaceDir(), CONFIG_FILE);
}

export function getSchemasDir(): string {
return path.join(getWorkspaceDir(), SCHEMAS_DIR);
}

export function loadWorkspaceConfig(): WorkspaceConfig | null {
const configPath = getWorkspaceConfigPath();
if (!fs.existsSync(configPath)) {
return null;
}
try {
const raw = fs.readFileSync(configPath, "utf8");
return JSON.parse(raw) as WorkspaceConfig;
} catch {
return null;
}
}

export function saveWorkspaceConfig(config: WorkspaceConfig): void {
const dir = getWorkspaceDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

const configPath = getWorkspaceConfigPath();
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
}

export function isValidCollectionName(collectionName: string): boolean {
return typeof collectionName === "string" && !collectionName.includes("/") && !collectionName.includes("\\") && !collectionName.includes("..");
}

export function saveSchemaFile(collectionName: string, schema: any): void {
if (!isValidCollectionName(collectionName)) {
throw new Error(`Invalid collection name: ${collectionName}`);
}

const schemasDir = getSchemasDir();
if (!fs.existsSync(schemasDir)) {
fs.mkdirSync(schemasDir, { recursive: true });
}

const filePath = path.join(schemasDir, `${collectionName}.json`);
fs.writeFileSync(filePath, JSON.stringify(schema, null, 2), "utf8");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function getLocalSchemas(): { name: string; schema: any }[] {
const schemasDir = getSchemasDir();
if (!fs.existsSync(schemasDir)) {
return [];
}

const files = fs.readdirSync(schemasDir).filter((file) => file.endsWith(".json"));
const schemas = [];

for (const file of files) {
try {
const filePath = path.join(schemasDir, file);
const raw = fs.readFileSync(filePath, "utf8");
schemas.push({
name: file.replace(".json", ""),
schema: JSON.parse(raw),
});
} catch (err) {
logger.warn(`Failed to parse schema file: ${file}. Skipping.`);
}
}

return schemas;
}

export function clearSchemaFiles(): void {
const schemasDir = getSchemasDir();
if (!fs.existsSync(schemasDir)) return;

const files = fs.readdirSync(schemasDir);
for (const file of files) {
if (file.endsWith(".json")) {
fs.unlinkSync(path.join(schemasDir, file));
}
}
}
Loading
Loading