diff --git a/.changeset/twenty-months-retire.md b/.changeset/twenty-months-retire.md new file mode 100644 index 00000000..49039ff0 --- /dev/null +++ b/.changeset/twenty-months-retire.md @@ -0,0 +1,5 @@ +--- +"@proofgeist/kit": minor +--- + +New "remove" command to remove pages, schemas, and data sources via the CLI diff --git a/cli/src/cli/add/data-source/filemaker.ts b/cli/src/cli/add/data-source/filemaker.ts index e8c7e5a8..ddb7a6c9 100644 --- a/cli/src/cli/add/data-source/filemaker.ts +++ b/cli/src/cli/add/data-source/filemaker.ts @@ -243,7 +243,6 @@ export async function promptForFileMakerDataSource({ setSettings(settings); addToFmschemaConfig({ - projectDir, dataSourceName: name, project, envNames: name === "filemaker" ? undefined : newDataSource.envNames, diff --git a/cli/src/cli/add/index.ts b/cli/src/cli/add/index.ts index 737ba989..5989d7b5 100644 --- a/cli/src/cli/add/index.ts +++ b/cli/src/cli/add/index.ts @@ -18,7 +18,7 @@ export const runAdd = async (name: string | undefined) => { const settings = getSettings(); if (name === "tanstack-query") { - return await runAddTanstackQueryCommand({ settings }); + return await runAddTanstackQueryCommand(); } const addType = abortIfCancel( diff --git a/cli/src/cli/add/page/post-install/table.ts b/cli/src/cli/add/page/post-install/table.ts index c8b506f6..522e2e4a 100644 --- a/cli/src/cli/add/page/post-install/table.ts +++ b/cli/src/cli/add/page/post-install/table.ts @@ -33,7 +33,6 @@ export const postInstallTable: TPostInstallFn = async ({ }); const allFieldNames = getFieldNamesForSchema({ - projectDir, schemaName, dataSourceName: dataSource.name, }); diff --git a/cli/src/cli/init.ts b/cli/src/cli/init.ts index f2e8e7a4..0aa9e1f2 100644 --- a/cli/src/cli/init.ts +++ b/cli/src/cli/init.ts @@ -286,7 +286,7 @@ export const runInit = async (name?: string, opts?: CliFlags) => { if (!cliOptions.noInstall) { await installDependencies({ projectDir }); - await runCodegenCommand({ projectDir }); + await runCodegenCommand(); } if (!cliOptions.noGit) { diff --git a/cli/src/cli/menu.ts b/cli/src/cli/menu.ts index 65e0876e..724f6027 100644 --- a/cli/src/cli/menu.ts +++ b/cli/src/cli/menu.ts @@ -6,6 +6,7 @@ import { DOCS_URL } from "~/consts.js"; import { getSettings } from "~/utils/parseSettings.js"; import { runAdd } from "./add/index.js"; import { runDeploy } from "./deploy/index.js"; +import { runRemove } from "./remove/index.js"; import { runTypegen } from "./typegen/index.js"; import { runUpgrade } from "./update/index.js"; import { abortIfCancel } from "./utils.js"; @@ -22,6 +23,11 @@ export const runMenu = async () => { value: "add", hint: "Add new pages, schemas, data sources, etc.", }, + { + label: "Remove Components", + value: "remove", + hint: "Remove pages, schemas, data sources, etc.", + }, { label: "Generate Types", value: "typegen", @@ -50,6 +56,9 @@ export const runMenu = async () => { case "add": await runAdd(undefined); break; + case "remove": + await runRemove(undefined); + break; case "docs": p.log.info(`Opening ${chalk.cyan(DOCS_URL)} in your browser...`); await open(DOCS_URL); diff --git a/cli/src/cli/remove/data-source.ts b/cli/src/cli/remove/data-source.ts new file mode 100644 index 00000000..bf23023f --- /dev/null +++ b/cli/src/cli/remove/data-source.ts @@ -0,0 +1,176 @@ +import path from "path"; +import * as p from "@clack/prompts"; +import { Command } from "commander"; +import dotenv from "dotenv"; +import fs from "fs-extra"; +import { z } from "zod"; + +import { + removeFromFmschemaConfig, + runCodegenCommand, +} from "~/generators/fmdapi.js"; +import { ciOption, debugOption } from "~/globalOptions.js"; +import { initProgramState, state } from "~/state.js"; +import { + getSettings, + setSettings, + type DataSource, +} from "~/utils/parseSettings.js"; +import { getNewProject } from "~/utils/ts-morph.js"; +import { + abortIfCancel, + ensureProofKitProject, + UserAbortedError, +} from "../utils.js"; + +function getDataSourceInfo(source: DataSource) { + if (source.type !== "fm") { + return source.type; + } + + const envFile = path.join(state.projectDir, ".env"); + if (fs.existsSync(envFile)) { + dotenv.config({ path: envFile }); + } + + const server = process.env[source.envNames.server] || "unknown server"; + const database = process.env[source.envNames.database] || "unknown database"; + + try { + // Format the server URL to be more readable + const serverUrl = new URL(server); + const formattedServer = serverUrl.hostname; + return `${formattedServer}/${database}`; + } catch (error) { + if (state.debug) { + console.error("Error parsing server URL:", error); + } + return `${server}/${database}`; + } +} + +export const runRemoveDataSourceCommand = async (name?: string) => { + const settings = getSettings(); + + if (settings.dataSources.length === 0) { + p.note("No data sources found in your project."); + return; + } + + let dataSourceName = name; + + // If no name provided, prompt for selection + if (!dataSourceName) { + dataSourceName = abortIfCancel( + await p.select({ + message: "Which data source do you want to remove?", + options: settings.dataSources.map((source) => { + let info = ""; + try { + info = getDataSourceInfo(source); + } catch (error) { + if (state.debug) { + console.error("Error getting data source info:", error); + } + info = "unknown connection"; + } + return { + label: `${source.name} (${info})`, + value: source.name, + }; + }), + }) + ); + } else { + // Validate that the provided name exists + const dataSourceExists = settings.dataSources.some( + (source) => source.name === dataSourceName + ); + if (!dataSourceExists) { + throw new Error( + `Data source "${dataSourceName}" not found in your project.` + ); + } + } + + let confirmed = true; + if (!state.ci) { + confirmed = abortIfCancel( + await p.confirm({ + message: `Are you sure you want to remove the data source "${dataSourceName}"? This will only remove it from your configuration, not replace any possible usage, which may cause TypeScript errors.`, + }) + ); + + if (!confirmed) throw new UserAbortedError(); + } + + // Get the data source before removing it + const dataSource = settings.dataSources.find( + (source) => source.name === dataSourceName + ); + + // Remove the data source from settings + settings.dataSources = settings.dataSources.filter( + (source) => source.name !== dataSourceName + ); + + // Save the updated settings + setSettings(settings); + + if (dataSource?.type === "fm") { + // For FileMaker data sources, remove from fmschema.config.mjs + removeFromFmschemaConfig({ + dataSourceName, + }); + + if (state.debug) { + p.note(`Removed schemas from fmschema.config.mjs`); + } + + // Remove the schema folder for this data source + const schemaFolderPath = path.join( + state.projectDir, + "src", + "config", + "schemas", + dataSourceName + ); + if (fs.existsSync(schemaFolderPath)) { + fs.removeSync(schemaFolderPath); + if (state.debug) { + p.note(`Removed schema folder at ${schemaFolderPath}`); + } + } + + // Run typegen to regenerate types + await runCodegenCommand(); + if (state.debug) { + p.note("Successfully regenerated types"); + } + } + + p.note(`Successfully removed data source "${dataSourceName}"`); +}; + +export const makeRemoveDataSourceCommand = () => { + const removeDataSourceCommand = new Command("data") + .description("Remove a data source from your project") + .option("--name ", "Name of the data source to remove") + .addOption(ciOption) + .addOption(debugOption) + .action(async (options) => { + const schema = z.object({ + name: z.string().optional(), + }); + const validated = schema.parse(options); + await runRemoveDataSourceCommand(validated.name); + }); + + removeDataSourceCommand.hook("preAction", (_thisCommand, actionCommand) => { + initProgramState(actionCommand.opts()); + state.baseCommand = "remove"; + ensureProofKitProject({ commandName: "remove" }); + }); + + return removeDataSourceCommand; +}; diff --git a/cli/src/cli/remove/index.ts b/cli/src/cli/remove/index.ts new file mode 100644 index 00000000..04a5f393 --- /dev/null +++ b/cli/src/cli/remove/index.ts @@ -0,0 +1,75 @@ +import * as p from "@clack/prompts"; +import { Command } from "commander"; + +import { ciOption, debugOption } from "~/globalOptions.js"; +import { initProgramState, state } from "~/state.js"; +import { getSettings } from "~/utils/parseSettings.js"; +import { abortIfCancel, ensureProofKitProject } from "../utils.js"; +import { + makeRemoveDataSourceCommand, + runRemoveDataSourceCommand, +} from "./data-source.js"; +import { makeRemovePageCommand, runRemovePageAction } from "./page.js"; +import { makeRemoveSchemaCommand, runRemoveSchemaAction } from "./schema.js"; + +export const runRemove = async (name: string | undefined) => { + const settings = getSettings(); + + const removeType = abortIfCancel( + await p.select({ + message: "What do you want to remove from your project?", + options: [ + { label: "Page", value: "page" }, + { + label: "Schema", + value: "schema", + hint: "remove a table or layout schema", + }, + ...(settings.appType === "browser" + ? [ + { + label: "Data Source", + value: "data", + hint: "remove a database or FileMaker connection", + }, + ] + : []), + ], + }) + ); + + if (removeType === "data") { + await runRemoveDataSourceCommand(); + } else if (removeType === "page") { + await runRemovePageAction(); + } else if (removeType === "schema") { + await runRemoveSchemaAction(); + } +}; + +export function makeRemoveCommand() { + const removeCommand = new Command("remove") + .description("Remove a component from your project") + .argument("[name]", "Type of component to remove") + .addOption(ciOption) + .addOption(debugOption) + .action(runRemove); + + removeCommand.hook("preAction", (_thisCommand, _actionCommand) => { + initProgramState(_actionCommand.opts()); + state.baseCommand = "remove"; + ensureProofKitProject({ commandName: "remove" }); + }); + removeCommand.hook("preSubcommand", (_thisCommand, _subCommand) => { + initProgramState(_subCommand.opts()); + state.baseCommand = "remove"; + ensureProofKitProject({ commandName: "remove" }); + }); + + // Add subcommands + removeCommand.addCommand(makeRemoveDataSourceCommand()); + removeCommand.addCommand(makeRemovePageCommand()); + removeCommand.addCommand(makeRemoveSchemaCommand()); + + return removeCommand; +} diff --git a/cli/src/cli/remove/page.ts b/cli/src/cli/remove/page.ts new file mode 100644 index 00000000..8a502ca0 --- /dev/null +++ b/cli/src/cli/remove/page.ts @@ -0,0 +1,224 @@ +import path from "path"; +import * as p from "@clack/prompts"; +import { Command } from "commander"; +import fs from "fs-extra"; +import { + Node, + SyntaxKind, + type Project, + type PropertyAssignment, +} from "ts-morph"; + +import { ciOption, debugOption } from "~/globalOptions.js"; +import { initProgramState, state } from "~/state.js"; +import { getSettings } from "~/utils/parseSettings.js"; +import { formatAndSaveSourceFiles, getNewProject } from "~/utils/ts-morph.js"; +import { abortIfCancel, ensureProofKitProject } from "../utils.js"; + +const getExistingRoutes = ( + project: Project +): { label: string; href: string }[] => { + const sourceFile = project.addSourceFileAtPath( + path.join(state.projectDir, "src/app/navigation.tsx") + ); + + const routes: { label: string; href: string }[] = []; + + // Get primary routes + const primaryRoutes = sourceFile + .getVariableDeclaration("primaryRoutes") + ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) + ?.getElements(); + + primaryRoutes?.forEach((element) => { + if (Node.isObjectLiteralExpression(element)) { + const labelProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "label" + ); + const hrefProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "href" + ); + + const label = labelProp?.getInitializer()?.getText().replace(/['"]/g, ""); + const href = hrefProp?.getInitializer()?.getText().replace(/['"]/g, ""); + + if (label && href) { + routes.push({ label, href }); + } + } + }); + + // Get secondary routes + const secondaryRoutes = sourceFile + .getVariableDeclaration("secondaryRoutes") + ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) + ?.getElements(); + + secondaryRoutes?.forEach((element) => { + if (Node.isObjectLiteralExpression(element)) { + const labelProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "label" + ); + const hrefProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "href" + ); + + const label = labelProp?.getInitializer()?.getText().replace(/['"]/g, ""); + const href = hrefProp?.getInitializer()?.getText().replace(/['"]/g, ""); + + if (label && href) { + routes.push({ label, href }); + } + } + }); + + return routes; +}; + +const removeRouteFromNav = async (project: Project, routeToRemove: string) => { + const sourceFile = project.addSourceFileAtPath( + path.join(state.projectDir, "src/app/navigation.tsx") + ); + + // Remove from primary routes + const primaryRoutes = sourceFile + .getVariableDeclaration("primaryRoutes") + ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression); + + if (primaryRoutes) { + const elements = primaryRoutes.getElements(); + for (let i = elements.length - 1; i >= 0; i--) { + const element = elements[i]; + if (Node.isObjectLiteralExpression(element)) { + const hrefProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "href" + ); + + const href = hrefProp?.getInitializer()?.getText().replace(/['"]/g, ""); + + if (href === routeToRemove) { + primaryRoutes.removeElement(i); + } + } + } + } + + // Remove from secondary routes + const secondaryRoutes = sourceFile + .getVariableDeclaration("secondaryRoutes") + ?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression); + + if (secondaryRoutes) { + const elements = secondaryRoutes.getElements(); + for (let i = elements.length - 1; i >= 0; i--) { + const element = elements[i]; + if (Node.isObjectLiteralExpression(element)) { + const hrefProp = element + .getProperties() + .find( + (prop): prop is PropertyAssignment => + Node.isPropertyAssignment(prop) && prop.getName() === "href" + ); + + const href = hrefProp?.getInitializer()?.getText().replace(/['"]/g, ""); + + if (href === routeToRemove) { + secondaryRoutes.removeElement(i); + } + } + } + } + + await formatAndSaveSourceFiles(project); +}; + +export const runRemovePageAction = async (routeName?: string) => { + const settings = getSettings(); + const projectDir = state.projectDir; + const project = getNewProject(projectDir); + + // Get existing routes + const routes = getExistingRoutes(project); + + if (routes.length === 0) { + return p.cancel("No pages found in the navigation."); + } + + if (!routeName) { + routeName = abortIfCancel( + await p.select({ + message: "Select the page to remove", + options: routes.map((route) => ({ + label: `${route.label} (${route.href})`, + value: route.href, + })), + }) + ); + } + + if (!routeName.startsWith("/")) { + routeName = `/${routeName}`; + } + + const pagePath = + state.appType === "browser" + ? path.join(projectDir, "src/app/(main)", routeName) + : path.join(projectDir, "src/routes", routeName); + + const spinner = p.spinner(); + spinner.start("Removing page"); + + try { + // Check if directory exists + if (!fs.existsSync(pagePath)) { + spinner.stop("Page not found!"); + return p.cancel(`Page at ${routeName} does not exist`); + } + + // Remove from navigation first + await removeRouteFromNav(project, routeName); + + // Remove the page directory + await fs.remove(pagePath); + + spinner.stop("Page removed successfully!"); + } catch (error) { + spinner.stop("Failed to remove page!"); + console.error("Error removing page:", error); + process.exit(1); + } +}; + +export const makeRemovePageCommand = () => { + const removePageCommand = new Command("page") + .description("Remove a page from your project") + .argument("[route]", "The route of the page to remove") + .addOption(ciOption) + .addOption(debugOption) + .action(async (route: string) => { + await runRemovePageAction(route); + }); + + removePageCommand.hook("preAction", (_thisCommand, actionCommand) => { + initProgramState(actionCommand.opts()); + state.baseCommand = "remove"; + ensureProofKitProject({ commandName: "remove" }); + }); + + return removePageCommand; +}; diff --git a/cli/src/cli/remove/schema.ts b/cli/src/cli/remove/schema.ts new file mode 100644 index 00000000..cc275ba9 --- /dev/null +++ b/cli/src/cli/remove/schema.ts @@ -0,0 +1,111 @@ +import * as p from "@clack/prompts"; +import { Command } from "commander"; +import { z } from "zod"; + +import { getExistingSchemas, removeLayout } from "~/generators/fmdapi.js"; +import { state } from "~/state.js"; +import { getSettings, type Settings } from "~/utils/parseSettings.js"; +import { abortIfCancel } from "../utils.js"; + +export const runRemoveSchemaAction = async (opts?: { + projectDir?: string; + settings?: Settings; + sourceName?: string; + schemaName?: string; +}) => { + const settings = opts?.settings ?? getSettings(); + const projectDir = opts?.projectDir ?? state.projectDir; + let sourceName = opts?.sourceName; + + // If there is more than one fm data source, prompt for which one to remove from + if ( + !sourceName && + settings.dataSources.filter((s) => s.type === "fm").length > 1 + ) { + const dataSourceName = await p.select({ + message: + "Which FileMaker data source do you want to remove a layout from?", + options: settings.dataSources + .filter((s) => s.type === "fm") + .map((s) => ({ label: s.name, value: s.name })), + }); + if (p.isCancel(dataSourceName)) { + p.cancel(); + process.exit(0); + } + sourceName = z.string().parse(dataSourceName); + } + + if (!sourceName) sourceName = "filemaker"; + + const dataSource = settings.dataSources + .filter((s) => s.type === "fm") + .find((s) => s.name === sourceName); + if (!dataSource) { + throw new Error( + `FileMaker data source ${sourceName} not found in your ProofKit config` + ); + } + + // Get existing schemas for this data source + const existingSchemas = getExistingSchemas({ + projectDir, + dataSourceName: sourceName, + }); + + if (existingSchemas.length === 0) { + p.note( + `No layouts found in data source "${sourceName}"`, + "Nothing to remove" + ); + return; + } + + // Show existing schemas and let user pick one to remove + const schemaToRemove = + opts?.schemaName ?? + abortIfCancel( + await p.select({ + message: "Select a layout to remove", + options: existingSchemas + .map((schema) => ({ + label: `${schema.layout} (${schema.schemaName})`, + value: schema.schemaName ?? "", + })) + .filter((opt) => opt.value !== ""), + }) + ); + + // Confirm removal + const confirmRemoval = await p.confirm({ + message: `Are you sure you want to remove the layout "${schemaToRemove}"?`, + initialValue: false, + }); + + if (p.isCancel(confirmRemoval) || !confirmRemoval) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + // Remove the schema + await removeLayout({ + projectDir, + dataSourceName: sourceName, + schemaName: schemaToRemove, + runCodegen: true, + }); + + p.outro(`Layout "${schemaToRemove}" has been removed from your project`); +}; + +export const makeRemoveSchemaCommand = () => { + const removeSchemaCommand = new Command("layout") + .alias("schema") + .description("Remove a layout from your fmschema file") + .action(async (opts: { settings: Settings }) => { + const settings = opts.settings; + await runRemoveSchemaAction({ settings }); + }); + + return removeSchemaCommand; +}; diff --git a/cli/src/cli/tanstack-query.ts b/cli/src/cli/tanstack-query.ts index 34fbd235..ea99887c 100644 --- a/cli/src/cli/tanstack-query.ts +++ b/cli/src/cli/tanstack-query.ts @@ -2,17 +2,11 @@ import * as p from "@clack/prompts"; import { Command } from "commander"; import { injectTanstackQuery } from "~/generators/tanstack-query.js"; -import { type Settings } from "~/utils/parseSettings.js"; -export const runAddTanstackQueryCommand = async ({ - settings, -}: { - settings: Settings; -}) => { - const projectDir = process.cwd(); +export const runAddTanstackQueryCommand = async () => { const spinner = p.spinner(); spinner.start("Adding Tanstack Query"); - await injectTanstackQuery({ settings, projectDir }); + await injectTanstackQuery(); spinner.stop("Tanstack Query added"); }; diff --git a/cli/src/cli/typegen/index.ts b/cli/src/cli/typegen/index.ts index bc35aa53..21e13df5 100644 --- a/cli/src/cli/typegen/index.ts +++ b/cli/src/cli/typegen/index.ts @@ -21,7 +21,7 @@ export async function runTypegen(opts: { settings: Settings }) { } if (generateFmTypes) { - await runCodegenCommand({ projectDir: process.cwd() }); + await runCodegenCommand(); } } diff --git a/cli/src/generators/fmdapi.ts b/cli/src/generators/fmdapi.ts index 8bb91dd2..712a7aca 100644 --- a/cli/src/generators/fmdapi.ts +++ b/cli/src/generators/fmdapi.ts @@ -48,7 +48,7 @@ export async function addLayout({ } if (runCodegen) { - await runCodegenCommand({ projectDir }); + await runCodegenCommand(); } } @@ -93,15 +93,12 @@ export async function addConfig({ } if (runCodegen) { - await runCodegenCommand({ projectDir }); + await runCodegenCommand(); } } -export async function runCodegenCommand({ - projectDir, -}: { - projectDir: string; -}) { +export async function runCodegenCommand() { + const projectDir = state.projectDir; const settings = getSettings(); if (settings.dataSources.length === 0) { console.log("no data sources found, skipping typegen"); @@ -266,16 +263,15 @@ export function getExistingSchemas({ } export function addToFmschemaConfig({ - projectDir, dataSourceName, project, envNames, }: { - projectDir: string; dataSourceName: string; project: Project; envNames?: z.infer; }) { + const projectDir = state.projectDir; const configFilePath = path.join(projectDir, "fmschema.config.mjs"); const alreadyExists = fs.existsSync(configFilePath); if (!alreadyExists) { @@ -368,14 +364,13 @@ export function addToFmschemaConfig({ } export function getFieldNamesForSchema({ - projectDir, schemaName, dataSourceName, }: { - projectDir: string; schemaName: string; dataSourceName: string; }) { + const projectDir = state.projectDir; const project = getNewProject(projectDir); const sourceFile = project.addSourceFileAtPath( path.join( @@ -416,3 +411,153 @@ export function getFieldNamesForSchema({ return fieldNames; } } + +export function removeFromFmschemaConfig({ + dataSourceName, + project, +}: { + dataSourceName: string; + project?: Project; +}) { + const projectDir = state.projectDir; + if (!project) project = getNewProject(projectDir); + + const configFilePath = path.join(projectDir, "fmschema.config.mjs"); + if (!fs.existsSync(configFilePath)) { + return; + } + + const sourceFile = project.addSourceFileAtPath(configFilePath); + const configVar = getConfigVarStatement(sourceFile); + + // Handle single config object case + if ( + configVar?.getInitializer()?.getKind() === + SyntaxKind.ObjectLiteralExpression + ) { + // If it's a single object and matches our data source, clear its schemas + const configObj = configVar + .getInitializer() + ?.asKind(SyntaxKind.ObjectLiteralExpression); + if (!configObj) return; + + const pathProp = configObj + .getProperty("path") + ?.asKind(SyntaxKind.PropertyAssignment); + const pathValue = pathProp + ?.getInitializer() + ?.getText() + ?.replace(/['"]/g, ""); + + if (pathValue?.includes(dataSourceName)) { + const schemasArray = configObj + .getProperty("schemas") + ?.asKind(SyntaxKind.PropertyAssignment) + ?.getInitializer() + ?.asKind(SyntaxKind.ArrayLiteralExpression); + + if (schemasArray) { + const emptyArray: string[] = []; + schemasArray.replaceWithText(`[${emptyArray.join(",")}]`); + } + } + return; + } + + // Handle array of configs case + const configArray = configVar?.getInitializerIfKind( + SyntaxKind.ArrayLiteralExpression + ); + if (configArray) { + const elements = configArray.getElements(); + const newElements = elements.filter((element) => { + if (!element.asKind(SyntaxKind.ObjectLiteralExpression)) { + return true; + } + const pathProp = element + .asKind(SyntaxKind.ObjectLiteralExpression) + ?.getProperty("path") + ?.asKind(SyntaxKind.PropertyAssignment); + const pathValue = pathProp + ?.getInitializer() + ?.getText() + ?.replace(/['"]/g, ""); + return !pathValue?.includes(dataSourceName); + }); + configArray.replaceWithText( + `[${newElements.map((el) => el.getText()).join(",")}]` + ); + } +} + +export async function removeLayout({ + projectDir = state.projectDir, + schemaName, + dataSourceName, + runCodegen = true, + ...args +}: { + projectDir?: string; + schemaName: string; + dataSourceName: string; + runCodegen?: boolean; + project?: Project; +}) { + const fmschemaConfig = path.join(projectDir, "fmschema.config.mjs"); + if (!fs.existsSync(fmschemaConfig)) { + throw new Error("fmschema.config.mjs not found"); + } + const project = args.project ?? getNewProject(projectDir); + + const sourceFile = project.addSourceFileAtPath(fmschemaConfig); + const schemasArray = getSchemasArray(sourceFile, dataSourceName); + if (!schemasArray) { + throw new Error("Could not find schemas array in config"); + } + + // Find and remove the schema with matching schemaName + const elements = schemasArray.getElements(); + if (!elements) { + throw new Error("Could not find schemas array in config"); + } + + const newElements = elements.filter((element) => { + if (!element.asKind(SyntaxKind.ObjectLiteralExpression)) { + return true; + } + const schemaNameProp = element + .asKind(SyntaxKind.ObjectLiteralExpression) + ?.getProperty("schemaName") + ?.asKind(SyntaxKind.PropertyAssignment); + const schemaNameValue = schemaNameProp + ?.getInitializer() + ?.getText() + ?.replace(/['"]/g, ""); + return schemaNameValue !== schemaName; + }); + + schemasArray.replaceWithText( + `[${newElements.map((el) => el.getText()).join(",")}]` + ); + + // Clean up generated schema file + const schemaFilePath = path.join( + projectDir, + "src", + "config", + "schemas", + dataSourceName, + `${schemaName}.ts` + ); + if (fs.existsSync(schemaFilePath)) { + fs.removeSync(schemaFilePath); + } + + if (!args.project) { + await formatAndSaveSourceFiles(project); + } + + if (runCodegen) { + await runCodegenCommand(); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 893a93ee..2c579609 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,6 +9,7 @@ import { proofGradient, renderTitle } from "~/utils/renderTitle.js"; import { makeAddCommand, runAdd } from "./cli/add/index.js"; import { makeDeployCommand } from "./cli/deploy/index.js"; import { runMenu } from "./cli/menu.js"; +import { makeRemoveCommand } from "./cli/remove/index.js"; import { makeTypegenCommand } from "./cli/typegen/index.js"; import { makeUpgradeCommand } from "./cli/update/makeUpgradeCommand.js"; import { UserAbortedError } from "./cli/utils.js"; @@ -62,6 +63,7 @@ const main = async () => { program.addCommand(makeInitCommand()); program.addCommand(makeAddCommand()); + program.addCommand(makeRemoveCommand()); program.addCommand(makeTypegenCommand()); program.addCommand(makeDeployCommand()); program.addCommand(makeUpgradeCommand()); diff --git a/cli/src/installers/proofkit-auth.ts b/cli/src/installers/proofkit-auth.ts index 79a007dc..da3451a5 100644 --- a/cli/src/installers/proofkit-auth.ts +++ b/cli/src/installers/proofkit-auth.ts @@ -142,7 +142,7 @@ export const proofkitAuthInstaller = async () => { "Successfully detected all required layouts in your FileMaker file."; } } - await runCodegenCommand({ projectDir }); + await runCodegenCommand(); spinner.succeed("Auth installed successfully"); }; diff --git a/cli/src/state.ts b/cli/src/state.ts index 3f71341f..483a35be 100644 --- a/cli/src/state.ts +++ b/cli/src/state.ts @@ -6,7 +6,7 @@ const schema = z debug: z.boolean().default(false), localBuild: z.boolean().default(false), baseCommand: z - .enum(["add", "init", "deploy", "upgrade"]) + .enum(["add", "init", "deploy", "upgrade", "remove"]) .optional() .catch(undefined), appType: z.enum(["browser", "webviewer"]).optional().catch(undefined),