diff --git a/sdks/urbackend-cli/package.json b/sdks/urbackend-cli/package.json index c7b653bd..4cc76053 100644 --- a/sdks/urbackend-cli/package.json +++ b/sdks/urbackend-cli/package.json @@ -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" @@ -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": { diff --git a/sdks/urbackend-cli/src/commands/generate/index.ts b/sdks/urbackend-cli/src/commands/generate/index.ts new file mode 100644 index 00000000..872e9f11 --- /dev/null +++ b/sdks/urbackend-cli/src/commands/generate/index.ts @@ -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"; + 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; +} + +export async function generateCommand(): Promise { + 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; + } +} diff --git a/sdks/urbackend-cli/src/commands/init/index.ts b/sdks/urbackend-cli/src/commands/init/index.ts new file mode 100644 index 00000000..b457314e --- /dev/null +++ b/sdks/urbackend-cli/src/commands/init/index.ts @@ -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 { + 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"); + } + + 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; + } +} diff --git a/sdks/urbackend-cli/src/commands/pull/index.ts b/sdks/urbackend-cli/src/commands/pull/index.ts new file mode 100644 index 00000000..367d32b2 --- /dev/null +++ b/sdks/urbackend-cli/src/commands/pull/index.ts @@ -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 { + 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++; + } + + 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; + } +} diff --git a/sdks/urbackend-cli/src/core/workspace.ts b/sdks/urbackend-cli/src/core/workspace.ts new file mode 100644 index 00000000..df598b14 --- /dev/null +++ b/sdks/urbackend-cli/src/core/workspace.ts @@ -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"); +} + +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)); + } + } +} diff --git a/sdks/urbackend-cli/src/index.ts b/sdks/urbackend-cli/src/index.ts index 6370dfc2..061d9286 100644 --- a/sdks/urbackend-cli/src/index.ts +++ b/sdks/urbackend-cli/src/index.ts @@ -9,13 +9,16 @@ import { collectionListCommand } from "./commands/collection/list.js"; import { collectionDeleteCommand } from "./commands/collection/delete.js"; import { statusCommand } from "./commands/status/index.js"; import { doctorCommand } from "./commands/doctor/index.js"; +import { initCommand } from "./commands/init/index.js"; +import { pullCommand } from "./commands/pull/index.js"; +import { generateCommand } from "./commands/generate/index.js"; const program = new Command(); program .name("ub") .description("Official urBackend CLI — manage projects, schemas, and more") - .version("0.1.0"); + .version("0.2.0", "-v, -V, --version", "Output the current version"); // ── Authentication ────────────────────────────────────────────────────────── @@ -93,6 +96,23 @@ program .option("--json", "Output results as JSON (useful for CI/AI agents)") .action((options) => doctorCommand(options)); +// ── Workspace ───────────────────────────────────────────────────────────────── + +program + .command("init [projectIdOrName]") + .description("Initialize a local urBackend project workspace") + .action((projectIdOrName) => initCommand(typeof projectIdOrName === "string" ? projectIdOrName : undefined)); + +program + .command("pull") + .description("Fetch the latest schemas for the linked project") + .action(pullCommand); + +program + .command("generate") + .description("Generate TypeScript definitions from local schemas") + .action(generateCommand); + // ── Parse ───────────────────────────────────────────────────────────────────── program.parseAsync(process.argv).catch((err: Error) => { diff --git a/sdks/urbackend-cli/src/types/config.ts b/sdks/urbackend-cli/src/types/config.ts index 21fb68ba..4de51058 100644 --- a/sdks/urbackend-cli/src/types/config.ts +++ b/sdks/urbackend-cli/src/types/config.ts @@ -3,3 +3,8 @@ export interface CLIConfig { pat?: string; currentProject?: string; } + +export interface WorkspaceConfig { + projectId?: string; + projectName?: string; +}