From 87f2ffdb9d43f6ba1c3a619fdec329977183fc42 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 7 Feb 2025 07:48:57 -0700 Subject: [PATCH 1/2] Add deployment and API key management features to OttoFMS CLI - Introduce new functions for creating API keys and managing deployments - Add support for deploying demo FileMaker files during data source setup - Implement Zod schema validation for deployment and status responses - Enhance FileMaker data source configuration with demo file deployment option --- .../cli/add/data-source/deploy-demo-file.ts | 96 +++++++++++++ cli/src/cli/add/data-source/filemaker.ts | 135 ++++++++++++------ cli/src/cli/ottofms.ts | 113 +++++++++++++-- cli/src/generators/auth.ts | 7 + 4 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 cli/src/cli/add/data-source/deploy-demo-file.ts diff --git a/cli/src/cli/add/data-source/deploy-demo-file.ts b/cli/src/cli/add/data-source/deploy-demo-file.ts new file mode 100644 index 00000000..a6e46429 --- /dev/null +++ b/cli/src/cli/add/data-source/deploy-demo-file.ts @@ -0,0 +1,96 @@ +import * as p from "@clack/prompts"; + +import { + createDataAPIKeyWithCredentials, + getDeploymentStatus, + startDeployment, +} from "~/cli/ottofms.js"; + +export const filename = "ProofKitDemo.fmp12"; + +export async function deployDemoFile({ + url, + token, + operation, +}: { + url: URL; + token: string; + operation: "install" | "replace"; +}): Promise<{ apiKey: string }> { + const deploymentJSON = { + scheduled: false, + label: "Install ProofKit Demo", + deployments: [ + { + name: "Install ProofKit Demo", + source: { + type: "url", + url: "https://proofkit.dev/proofkit-demo/manifest.json", + }, + fileOperations: [ + { + target: { + fileName: filename, + }, + operation, + source: { + fileName: "ProofKitDemo.fmp12", + }, + location: { + folder: "default", + subFolder: "", + }, + }, + ], + concurrency: 1, + options: { + closeFilesAfterBuild: false, + keepFilesClosedAfterComplete: false, + transferContainerData: false, + }, + }, + ], + abortRemaining: false, + }; + + const spinner = p.spinner(); + spinner.start("Deploying ProofKit Demo file..."); + + const { + response: { subDeploymentIds }, + } = await startDeployment({ + payload: deploymentJSON, + url, + token, + }); + + const deploymentId = subDeploymentIds[0]!; + + while (true) { + // wait 2.5 seconds, then poll the status again + await new Promise((resolve) => setTimeout(resolve, 2500)); + + const { + response: { status, running }, + } = await getDeploymentStatus({ + url, + token, + deploymentId, + }); + if (!running) { + if (status !== "complete") throw new Error("Deployment didn't complete"); + break; + } + } + + const { apiKey } = await createDataAPIKeyWithCredentials({ + filename, + username: "admin", + password: "admin", + url, + }); + + spinner.stop(); + + return { apiKey }; +} diff --git a/cli/src/cli/add/data-source/filemaker.ts b/cli/src/cli/add/data-source/filemaker.ts index 8d28f181..a14afec6 100644 --- a/cli/src/cli/add/data-source/filemaker.ts +++ b/cli/src/cli/add/data-source/filemaker.ts @@ -22,6 +22,7 @@ import { import { formatAndSaveSourceFiles, getNewProject } from "~/utils/ts-morph.js"; import { validateAppName } from "~/utils/validateAppName.js"; import { runAddSchemaAction } from "../fmschema.js"; +import { deployDemoFile, filename } from "./deploy-demo-file.js"; export async function promptForFileMakerDataSource({ projectDir, @@ -57,64 +58,106 @@ export async function promptForFileMakerDataSource({ opts.adminApiKey || (await getOttoFMSToken({ url: server.url })).token; const fileList = await listFiles({ url: server.url, token }); - const selectedFile = - opts.fileName || - abortIfCancel( - await p.select({ - message: `Which file would you like to connect to? ${chalk.dim(`(TIP: Select the file where your data is stored)`)}`, - maxItems: 10, - options: fileList - .sort((a, b) => a.filename.localeCompare(b.filename)) - .map((file) => ({ - value: file.filename, - label: file.filename, - })), - }) - ); - const fmFile = selectedFile; + const demoFileExists = fileList + .map((f) => f.filename.replace(".fmp12", "")) + .includes(filename.replace(".fmp12", "")); + let fmFile = opts.fileName; + while (true) { + fmFile = + opts.fileName || + abortIfCancel( + await p.select({ + message: `Which file would you like to connect to? ${chalk.dim(`(TIP: Select the file where your data is stored)`)}`, + maxItems: 10, + options: [ + { + value: "$deployDemoFile", + label: "Deploy NEW ProofKit Demo File", + hint: "Use OttoFMS to deploy a new file for testing", + }, + ...fileList + .sort((a, b) => a.filename.localeCompare(b.filename)) + .map((file) => ({ + value: file.filename, + label: file.filename, + })), + ], + }) + ); - const allApiKeys = await listAPIKeys({ url: server.url, token }); - const thisFileApiKeys = allApiKeys.filter((key) => key.database === fmFile); + if (fmFile !== "$deployDemoFile") break; - let dataApiKey = opts.dataApiKey; - if (!dataApiKey && thisFileApiKeys.length > 0) { - const selectedKey = abortIfCancel( - await p.select({ - message: "Which API key would you like to use?", - options: [ - ...thisFileApiKeys.map((key) => ({ - value: key.key, - label: `${chalk.bold(key.label)} - ${key.user}`, - hint: `${key.key.slice(0, 5)}...${key.key.slice(-4)}`, - })), - { - value: "create", - label: "Create a new API key", - hint: "Requires FileMaker credentials for this file", - }, - ], - }) - ); - if (typeof selectedKey !== "string") throw new Error("Invalid key"); - if (selectedKey !== "create") dataApiKey = selectedKey; + if (demoFileExists) { + const replace = abortIfCancel( + await p.confirm({ + message: + "The demo file already exists, do you want to replace it with a fresh copy?", + active: "Yes, replace", + inactive: "No, select another file", + initialValue: false, + }) + ); + if (replace) break; + } else { + break; + } } - if (!dataApiKey) { - // data api was not provided, prompt to create a new one - const resp = await createDataAPIKey({ - filename: fmFile, + if (!fmFile) throw new Error("No file selected"); + + let dataApiKey = opts.dataApiKey; + if (fmFile === "$deployDemoFile") { + const { apiKey } = await deployDemoFile({ url: server.url, + token, + operation: demoFileExists ? "replace" : "install", }); - dataApiKey = resp.apiKey; - } + dataApiKey = apiKey; + fmFile = filename; + opts.layoutName = opts.layoutName ?? "API_Contacts"; + opts.schemaName = opts.schemaName ?? "Contacts"; + } else { + const allApiKeys = await listAPIKeys({ url: server.url, token }); + const thisFileApiKeys = allApiKeys.filter((key) => key.database === fmFile); + + if (!dataApiKey && thisFileApiKeys.length > 0) { + const selectedKey = abortIfCancel( + await p.select({ + message: "Which API key would you like to use?", + options: [ + ...thisFileApiKeys.map((key) => ({ + value: key.key, + label: `${chalk.bold(key.label)} - ${key.user}`, + hint: `${key.key.slice(0, 5)}...${key.key.slice(-4)}`, + })), + { + value: "create", + label: "Create a new API key", + hint: "Requires FileMaker credentials for this file", + }, + ], + }) + ); + if (typeof selectedKey !== "string") throw new Error("Invalid key"); + if (selectedKey !== "create") dataApiKey = selectedKey; + } + if (!dataApiKey) { + // data api was not provided, prompt to create a new one + const resp = await createDataAPIKey({ + filename: fmFile, + url: server.url, + }); + dataApiKey = resp.apiKey; + } + } if (!dataApiKey) throw new Error("No API key"); const name = existingFmDataSourceNames.length === 0 ? "filemaker" : opts.name ?? - ( + abortIfCancel( await p.text({ message: "What do you want to call this data source?", validate: (value) => { @@ -128,7 +171,7 @@ export async function promptForFileMakerDataSource({ return validateAppName(value); }, }) - ).toString(); + ); const newDataSource: z.infer = { type: "fm", diff --git a/cli/src/cli/ottofms.ts b/cli/src/cli/ottofms.ts index c8c64c3f..7b3b48dc 100644 --- a/cli/src/cli/ottofms.ts +++ b/cli/src/cli/ottofms.ts @@ -3,6 +3,7 @@ import axios, { AxiosError } from "axios"; import chalk from "chalk"; import open from "open"; import randomstring from "randomstring"; +import { z } from "zod"; import { abortIfCancel } from "./utils.js"; @@ -161,17 +162,14 @@ export async function createDataAPIKey({ ); try { - const response = await axios.post( - `${url.origin}/otto/api/api-key/create-only`, - { - database: filename, - label: "For FM Web App", - user: username, - pass: password, - } - ); + const response = await createDataAPIKeyWithCredentials({ + url, + filename, + username, + password, + }); - return { apiKey: response.data.response.key }; + return response; } catch (error) { if (!(error instanceof AxiosError)) { clack.log.error( @@ -213,3 +211,98 @@ ${url.origin}/otto/app/api-keys` } } } + +export async function createDataAPIKeyWithCredentials({ + url, + filename, + username, + password, +}: { + url: URL; + filename: string; + username: string; + password: string; +}) { + const response = await axios.post( + `${url.origin}/otto/api/api-key/create-only`, + { + database: filename, + label: "For FM Web App", + user: username, + pass: password, + } + ); + + return { apiKey: response.data.response.key }; +} + +export async function startDeployment({ + payload, + url, + token, +}: { + payload: any; + url: URL; + token: string; +}) { + const responseSchema = z.object({ + response: z.object({ + started: z.boolean(), + batchId: z.number(), + subDeploymentIds: z.array(z.number()), + }), + messages: z.array(z.object({ code: z.number(), text: z.string() })), + }); + + const response = await axios.post( + `${url.origin}/otto/api/deployment`, + payload, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return responseSchema.parse(response.data); +} + +export async function getDeploymentStatus({ + url, + token, + deploymentId, +}: { + url: URL; + token: string; + deploymentId: number; +}) { + const schema = z.object({ + response: z.object({ + id: z.number(), + status: z.enum([ + "queued", + "running", + "scheduled", + "complete", + "aborted", + "unknown", + ]), + running: z.coerce.boolean(), + created_at: z.string(), + started_at: z.string(), + updated_at: z.string(), + }), + messages: z.array(z.object({ code: z.number(), text: z.string() })), + }); + + const response = await axios.get( + `${url.origin}/otto/api/deployment/${deploymentId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return schema.parse(response.data); +} diff --git a/cli/src/generators/auth.ts b/cli/src/generators/auth.ts index 23a86e1d..3714b232 100644 --- a/cli/src/generators/auth.ts +++ b/cli/src/generators/auth.ts @@ -21,6 +21,13 @@ export async function addAuth({ const settings = getSettings(); if (settings.auth.type !== "none") { throw new Error("Auth already exists"); + } else if ( + !settings.dataSources.some((o) => o.type === "fm") && + options.type === "fmaddon" + ) { + throw new Error( + "A FileMaker data source is required to use the FM Add-on Auth" + ); } if (options.type === "clerk") { From 6073cfeba9f071b0b329e3412282e27b85bffddc Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Fri, 7 Feb 2025 07:49:54 -0700 Subject: [PATCH 2/2] changeset --- .changeset/odd-tables-clean.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/odd-tables-clean.md diff --git a/.changeset/odd-tables-clean.md b/.changeset/odd-tables-clean.md new file mode 100644 index 00000000..2c198b3d --- /dev/null +++ b/.changeset/odd-tables-clean.md @@ -0,0 +1,5 @@ +--- +"@proofgeist/kit": minor +--- + +Allow deploying a demo file to your server instead of having to pick an existing file