Skip to content

Commit 869d208

Browse files
authored
Added 3 more CLI commands (#345)
* Registered the init, pull, genrate command * added core utilities * added interface for workspace config * created genrate command which converts json file into TypeScript Definition file * created pull command which fetch the structure of exact schema * created init command * swap lint eslint src with tsc --noEmit * Complete the review by CodeRabbit * Added input for version cases * Updated the version - 0.2.0 * Make pull cmd to look through all the incoming remote collections name first
1 parent 1c0dcc1 commit 869d208

7 files changed

Lines changed: 390 additions & 3 deletions

File tree

sdks/urbackend-cli/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@urbackend/cli",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Official CLI for urBackend — manage projects, schemas, and more from your terminal",
55
"publishConfig": {
66
"access": "public"
@@ -17,7 +17,7 @@
1717
"scripts": {
1818
"build": "tsup --config tsup.config.ts",
1919
"dev": "tsup --config tsup.config.ts --watch",
20-
"lint": "eslint src",
20+
"lint": "tsc --noEmit",
2121
"format": "prettier --write \"src/**/*.ts\""
2222
},
2323
"dependencies": {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { getLocalSchemas } from "../../core/workspace.js";
4+
import { logger } from "../../core/logger.js";
5+
import type { CollectionField } from "../../types/project.js";
6+
7+
function mapType(field: CollectionField, indent = " "): string {
8+
switch (field.type) {
9+
case "String":
10+
return "string";
11+
case "Number":
12+
return "number";
13+
case "Boolean":
14+
return "boolean";
15+
case "Date":
16+
return "string | Date";
17+
case "Ref":
18+
return "string";
19+
case "Array":
20+
if (field.fields && field.fields.length > 0) {
21+
// We only support arrays of objects for now, or array of singular types if it's a single field with empty key
22+
if (field.fields.length === 1 && !field.fields[0].key) {
23+
return `Array<${mapType(field.fields[0], indent)}>`;
24+
}
25+
return `Array<${parseFields(field.fields, indent + " ")}>`;
26+
}
27+
return "any[]";
28+
case "Object":
29+
if (field.fields && field.fields.length > 0) {
30+
return parseFields(field.fields, indent + " ");
31+
}
32+
return "Record<string, any>";
33+
default:
34+
return "any";
35+
}
36+
}
37+
38+
function parseFields(fields: CollectionField[], indent: string): string {
39+
let out = "{\n";
40+
for (const f of fields) {
41+
const isOptional = !f.required ? "?" : "";
42+
const typeStr = mapType(f, indent);
43+
out += `${indent} "${f.key}"${isOptional}: ${typeStr};\n`;
44+
}
45+
out += `${indent}}`;
46+
return out;
47+
}
48+
49+
export async function generateCommand(): Promise<void> {
50+
const schemas = getLocalSchemas();
51+
52+
if (schemas.length === 0) {
53+
logger.error("No schemas found in .ub/schemas/. Run 'ub pull' first.");
54+
process.exitCode = 1;
55+
return;
56+
}
57+
58+
let dtsContent = `// Auto-generated by urBackend CLI\n// Do not edit this file directly\n\n`;
59+
dtsContent += `export interface UrBackendTypes {\n`;
60+
61+
for (const { name, schema } of schemas) {
62+
if (!schema.model || !Array.isArray(schema.model)) continue;
63+
64+
dtsContent += ` "${name}": {\n`;
65+
dtsContent += ` _id: string;\n`;
66+
67+
for (const field of schema.model as CollectionField[]) {
68+
const isOptional = !field.required ? "?" : "";
69+
const typeStr = mapType(field, " ");
70+
dtsContent += ` "${field.key}"${isOptional}: ${typeStr};\n`;
71+
}
72+
73+
dtsContent += ` createdAt: string | Date;\n`;
74+
dtsContent += ` updatedAt: string | Date;\n`;
75+
dtsContent += ` };\n`;
76+
}
77+
78+
dtsContent += `}\n`;
79+
80+
const outputPath = path.join(process.cwd(), "urbackend.d.ts");
81+
82+
try {
83+
fs.writeFileSync(outputPath, dtsContent, "utf8");
84+
logger.success(`Generated TypeScript definitions at ./urbackend.d.ts`);
85+
} catch (error) {
86+
logger.error(`Failed to write urbackend.d.ts: ${(error as Error).message}`);
87+
process.exitCode = 1;
88+
}
89+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { listProjects } from "../../services/project.service.js";
4+
import { getToken } from "../../core/config.js";
5+
import { saveWorkspaceConfig, getWorkspaceDir } from "../../core/workspace.js";
6+
import { prompt } from "../../utils/prompt.js";
7+
import { APIError } from "../../core/errors.js";
8+
import { logger } from "../../core/logger.js";
9+
10+
export async function initCommand(projectIdOrName?: string): Promise<void> {
11+
const token = getToken();
12+
if (!token) {
13+
logger.error("You are not logged in. Run 'ub login' first.");
14+
process.exitCode = 1;
15+
return;
16+
}
17+
18+
try {
19+
const projects = await listProjects();
20+
21+
if (projects.length === 0) {
22+
logger.info("No projects found. Create one at dashboard.urbackend.bitbros.in");
23+
return;
24+
}
25+
26+
let selectedId: string | undefined;
27+
let selectedName: string | undefined;
28+
29+
if (projectIdOrName) {
30+
const match = projects.find(
31+
(p) =>
32+
p._id === projectIdOrName ||
33+
p.name.toLowerCase() === projectIdOrName.toLowerCase(),
34+
);
35+
if (!match) {
36+
logger.error(`No project found matching "${projectIdOrName}".`);
37+
logger.info("Run 'ub project list' to see available projects.");
38+
process.exitCode = 1;
39+
return;
40+
}
41+
selectedId = match._id;
42+
selectedName = match.name;
43+
} else {
44+
console.log("\nAvailable projects:\n");
45+
projects.forEach((p, i) => {
46+
console.log(` [${i + 1}] ${p.name} (${p._id})`);
47+
});
48+
console.log();
49+
50+
const answer = await prompt("Select a project to link this directory to: ");
51+
if (!/^\d+$/.test(answer)) {
52+
logger.error("Invalid selection.");
53+
process.exitCode = 1;
54+
return;
55+
}
56+
const index = Number(answer) - 1;
57+
if (isNaN(index) || index < 0 || index >= projects.length) {
58+
logger.error("Invalid selection.");
59+
process.exitCode = 1;
60+
return;
61+
}
62+
63+
selectedId = projects[index]._id;
64+
selectedName = projects[index].name;
65+
}
66+
67+
saveWorkspaceConfig({ projectId: selectedId, projectName: selectedName });
68+
69+
// Update .gitignore if it exists
70+
const gitignorePath = path.join(process.cwd(), ".gitignore");
71+
if (fs.existsSync(gitignorePath)) {
72+
const gitignore = fs.readFileSync(gitignorePath, "utf8");
73+
const lines = gitignore.split(/\r?\n/).map(line => line.trim());
74+
if (!lines.includes(".ub") && !lines.includes("/.ub") && !lines.includes(".ub/")) {
75+
fs.appendFileSync(gitignorePath, "\n# urBackend local workspace\n.ub\n", "utf8");
76+
logger.info("Added .ub to .gitignore");
77+
}
78+
} else {
79+
fs.writeFileSync(gitignorePath, "# urBackend local workspace\n.ub\n", "utf8");
80+
logger.info("Created .gitignore and added .ub");
81+
}
82+
83+
logger.success(`Successfully initialized urBackend workspace in .ub/`);
84+
logger.info(`Linked to project: ${selectedName} (${selectedId})`);
85+
console.log(`\nNext, run 'ub pull' to download your schemas.`);
86+
} catch (error) {
87+
if (error instanceof APIError) {
88+
if (error.status === 401) {
89+
logger.error("Token is invalid or expired. Run 'ub login' to re-authenticate.");
90+
} else {
91+
logger.error(error.message);
92+
}
93+
process.exitCode = 1;
94+
return;
95+
}
96+
logger.error("Unable to connect to the urBackend API.");
97+
process.exitCode = 1;
98+
}
99+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { loadWorkspaceConfig, saveSchemaFile, clearSchemaFiles, isValidCollectionName } from "../../core/workspace.js";
2+
import { getProject } from "../../services/project.service.js";
3+
import { getToken } from "../../core/config.js";
4+
import { APIError } from "../../core/errors.js";
5+
import { logger } from "../../core/logger.js";
6+
import { label } from "../../utils/format.js";
7+
8+
export async function pullCommand(): Promise<void> {
9+
const token = getToken();
10+
if (!token) {
11+
logger.error("You are not logged in. Run 'ub login' first.");
12+
process.exitCode = 1;
13+
return;
14+
}
15+
16+
const workspaceConfig = loadWorkspaceConfig();
17+
if (!workspaceConfig || !workspaceConfig.projectId) {
18+
logger.error(
19+
"No project linked to this directory. Run 'ub init' to link a project first."
20+
);
21+
process.exitCode = 1;
22+
return;
23+
}
24+
25+
const { projectId, projectName } = workspaceConfig;
26+
logger.info(`Fetching schemas for ${projectName ? projectName + " " : ""}(${projectId})...`);
27+
28+
try {
29+
const project = await getProject(projectId);
30+
31+
if (!project.collections || project.collections.length === 0) {
32+
logger.info("This project has no collections defined yet.");
33+
return;
34+
}
35+
36+
let count = 0;
37+
38+
// Validate all remote collection names up front before clearing local schemas
39+
for (const collection of project.collections) {
40+
if (!isValidCollectionName(collection.name)) {
41+
throw new Error(`Invalid collection name received from remote: ${collection.name}`);
42+
}
43+
}
44+
45+
clearSchemaFiles();
46+
for (const collection of project.collections) {
47+
saveSchemaFile(collection.name, {
48+
name: collection.name,
49+
model: collection.model,
50+
rls: collection.rls,
51+
});
52+
count++;
53+
}
54+
55+
logger.success(`Successfully pulled ${count} collection schema(s) into .ub/schemas/`);
56+
console.log();
57+
for (const collection of project.collections) {
58+
console.log(` ${label("schema")} ${collection.name}.json`);
59+
}
60+
console.log(`\nNext, run 'ub generate' to create your TypeScript types.`);
61+
} catch (error) {
62+
if (error instanceof APIError) {
63+
if (error.status === 401) {
64+
logger.error("Token is invalid or expired. Run 'ub login' to re-authenticate.");
65+
} else if (error.status === 403) {
66+
logger.error("You do not have permission to access this project.");
67+
} else {
68+
logger.error(error.message);
69+
}
70+
process.exitCode = 1;
71+
return;
72+
}
73+
logger.error("Unable to connect to the urBackend API.");
74+
process.exitCode = 1;
75+
}
76+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type { WorkspaceConfig } from "../types/config.js";
4+
import { logger } from "./logger.js";
5+
6+
const WORKSPACE_DIR = ".ub";
7+
const CONFIG_FILE = "config.json";
8+
const SCHEMAS_DIR = "schemas";
9+
10+
export function getWorkspaceDir(): string {
11+
return path.join(process.cwd(), WORKSPACE_DIR);
12+
}
13+
14+
export function getWorkspaceConfigPath(): string {
15+
return path.join(getWorkspaceDir(), CONFIG_FILE);
16+
}
17+
18+
export function getSchemasDir(): string {
19+
return path.join(getWorkspaceDir(), SCHEMAS_DIR);
20+
}
21+
22+
export function loadWorkspaceConfig(): WorkspaceConfig | null {
23+
const configPath = getWorkspaceConfigPath();
24+
if (!fs.existsSync(configPath)) {
25+
return null;
26+
}
27+
try {
28+
const raw = fs.readFileSync(configPath, "utf8");
29+
return JSON.parse(raw) as WorkspaceConfig;
30+
} catch {
31+
return null;
32+
}
33+
}
34+
35+
export function saveWorkspaceConfig(config: WorkspaceConfig): void {
36+
const dir = getWorkspaceDir();
37+
if (!fs.existsSync(dir)) {
38+
fs.mkdirSync(dir, { recursive: true });
39+
}
40+
41+
const configPath = getWorkspaceConfigPath();
42+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
43+
}
44+
45+
export function isValidCollectionName(collectionName: string): boolean {
46+
return typeof collectionName === "string" && !collectionName.includes("/") && !collectionName.includes("\\") && !collectionName.includes("..");
47+
}
48+
49+
export function saveSchemaFile(collectionName: string, schema: any): void {
50+
if (!isValidCollectionName(collectionName)) {
51+
throw new Error(`Invalid collection name: ${collectionName}`);
52+
}
53+
54+
const schemasDir = getSchemasDir();
55+
if (!fs.existsSync(schemasDir)) {
56+
fs.mkdirSync(schemasDir, { recursive: true });
57+
}
58+
59+
const filePath = path.join(schemasDir, `${collectionName}.json`);
60+
fs.writeFileSync(filePath, JSON.stringify(schema, null, 2), "utf8");
61+
}
62+
63+
export function getLocalSchemas(): { name: string; schema: any }[] {
64+
const schemasDir = getSchemasDir();
65+
if (!fs.existsSync(schemasDir)) {
66+
return [];
67+
}
68+
69+
const files = fs.readdirSync(schemasDir).filter((file) => file.endsWith(".json"));
70+
const schemas = [];
71+
72+
for (const file of files) {
73+
try {
74+
const filePath = path.join(schemasDir, file);
75+
const raw = fs.readFileSync(filePath, "utf8");
76+
schemas.push({
77+
name: file.replace(".json", ""),
78+
schema: JSON.parse(raw),
79+
});
80+
} catch (err) {
81+
logger.warn(`Failed to parse schema file: ${file}. Skipping.`);
82+
}
83+
}
84+
85+
return schemas;
86+
}
87+
88+
export function clearSchemaFiles(): void {
89+
const schemasDir = getSchemasDir();
90+
if (!fs.existsSync(schemasDir)) return;
91+
92+
const files = fs.readdirSync(schemasDir);
93+
for (const file of files) {
94+
if (file.endsWith(".json")) {
95+
fs.unlinkSync(path.join(schemasDir, file));
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)