diff --git a/packages/cli/src/cli/add/addon.ts b/packages/cli/src/cli/add/addon.ts new file mode 100644 index 00000000..c79fb511 --- /dev/null +++ b/packages/cli/src/cli/add/addon.ts @@ -0,0 +1,62 @@ +import { Command } from "commander"; +import { select } from "~/cli/prompts.js"; +import { debugOption, nonInteractiveOption } from "~/globalOptions.js"; +import { installFmAddonExplicitly } from "~/installers/install-fm-addon.js"; +import { initProgramState, isNonInteractiveMode } from "~/state.js"; +import { getSettings } from "~/utils/parseSettings.js"; +import { abortIfCancel, ensureProofKitProject } from "../utils.js"; + +type AddonTarget = "webviewer" | "auth"; + +async function resolveAddonTarget(name?: string): Promise { + if (name === "webviewer" || name === "auth") { + return name; + } + + if (isNonInteractiveMode()) { + throw new Error("Addon target is required in non-interactive mode. Use `proofkit add addon webviewer`."); + } + + return abortIfCancel( + await select({ + message: "Which add-on do you want to install locally?", + options: [ + { value: "webviewer", label: "WebViewer", hint: "ProofKit WebViewer add-on" }, + { value: "auth", label: "Auth", hint: "ProofKit Auth add-on" }, + ], + }), + ) as AddonTarget; +} + +export async function runAddAddonAction(targetName?: string) { + ensureProofKitProject({ commandName: "add addon" }); + const settings = getSettings(); + const target = await resolveAddonTarget(targetName); + + if (target === "webviewer" && settings.appType !== "webviewer") { + throw new Error("The WebViewer add-on can only be added from a WebViewer ProofKit project."); + } + + if (target === "auth" && settings.appType !== "browser") { + throw new Error("The auth add-on can only be added from a browser ProofKit project."); + } + + await installFmAddonExplicitly({ addonName: target === "webviewer" ? "wv" : "auth" }); +} + +export const makeAddAddonCommand = () => { + const addAddonCommand = new Command("addon") + .description("Install or update local FileMaker add-on files") + .argument("[target]", "Add-on to install locally (webviewer or auth)") + .addOption(nonInteractiveOption) + .addOption(debugOption) + .action(async (target) => { + await runAddAddonAction(target); + }); + + addAddonCommand.hook("preAction", (thisCommand) => { + initProgramState(thisCommand.opts()); + }); + + return addAddonCommand; +}; diff --git a/packages/cli/src/cli/add/index.ts b/packages/cli/src/cli/add/index.ts index 8b976d11..a505b1e1 100644 --- a/packages/cli/src/cli/add/index.ts +++ b/packages/cli/src/cli/add/index.ts @@ -10,6 +10,7 @@ import { getSettings, type Settings } from "~/utils/parseSettings.js"; import { runAddReactEmailCommand } from "../react-email.js"; import { runAddTanstackQueryCommand } from "../tanstack-query.js"; import { abortIfCancel, ensureProofKitProject } from "../utils.js"; +import { makeAddAddonCommand, runAddAddonAction } from "./addon.js"; import { makeAddAuthCommand, runAddAuthAction } from "./auth.js"; import { makeAddDataSourceCommand, runAddDataSourceCommand } from "./data-source/index.js"; import { makeAddSchemaCommand, runAddSchemaAction } from "./fmschema.js"; @@ -98,7 +99,10 @@ const runAddFromRegistry = async (_options?: { noInstall?: boolean }) => { } }; -export const runAdd = async (name: string | undefined, options?: { noInstall?: boolean }) => { +export const runAdd = async (name: string | undefined, options?: { noInstall?: boolean; target?: string }) => { + if (name === "addon") { + return await runAddAddonAction(options?.target); + } if (name === "tanstack-query") { return await runAddTanstackQueryCommand(); } @@ -182,6 +186,7 @@ export const makeAddCommand = () => { }); addCommand.addCommand(makeAddAuthCommand()); + addCommand.addCommand(makeAddAddonCommand()); addCommand.addCommand(makeAddPageCommand()); addCommand.addCommand(makeAddSchemaCommand()); addCommand.addCommand(makeAddDataSourceCommand()); diff --git a/packages/cli/src/core/executeInitPlan.ts b/packages/cli/src/core/executeInitPlan.ts index ed5470af..37312239 100644 --- a/packages/cli/src/core/executeInitPlan.ts +++ b/packages/cli/src/core/executeInitPlan.ts @@ -30,7 +30,7 @@ const formatCommand = (command: string) => chalk.cyan(command); const formatHeading = (heading: string) => chalk.bold(heading); const formatPath = (value: string) => chalk.yellow(value); -function renderNextSteps(plan: InitPlan) { +function renderNextSteps(plan: InitPlan, additionalSteps: string[] = []) { const lines = [ `${formatHeading("Project root:")} ${formatCommand(`cd ${formatPath(plan.request.appDir)}`)}`, "", @@ -56,6 +56,10 @@ function renderNextSteps(plan: InitPlan) { ` ${formatCommand(`${plan.packageManagerCommand} typegen`)}`, ` ${formatCommand(`${plan.packageManagerCommand} launch-fm`)}`, ); + + if (additionalSteps.length > 0) { + lines.push(...additionalSteps.map((step) => ` ${formatCommand(step)}`)); + } } lines.push( @@ -145,6 +149,7 @@ export const prepareDirectory = (plan: InitPlan) => export const executeInitPlan = (plan: InitPlan) => Effect.gen(function* () { + const cliContext = yield* CliContext; const fs = yield* FileSystemService; const consoleService = yield* ConsoleService; const settingsService = yield* SettingsService; @@ -153,6 +158,7 @@ export const executeInitPlan = (plan: InitPlan) => const gitService = yield* GitService; const codegenService = yield* CodegenService; const packageManagerService = yield* PackageManagerService; + const additionalNextSteps: string[] = []; yield* prepareDirectory(plan); @@ -212,6 +218,31 @@ export const executeInitPlan = (plan: InitPlan) => yield* Effect.promise(() => settingsService.writeSettings(plan.targetDir, nextSettings)); } + if (plan.tasks.checkWebViewerAddon) { + yield* Effect.promise(async () => { + try { + const { checkForWebViewerLayouts, getWebViewerAddonMessages } = await import( + "~/installers/proofkit-webviewer.js" + ); + const status = await checkForWebViewerLayouts(plan.targetDir); + const messages = getWebViewerAddonMessages(status); + + for (const message of messages.warn) { + consoleService.warn(message); + } + for (const message of messages.info) { + consoleService.info(message); + } + if (cliContext.nonInteractive) { + additionalNextSteps.push(...messages.nextSteps); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + consoleService.warn(`Could not inspect the ProofKit WebViewer add-on (${message}).`); + } + }); + } + if (plan.tasks.runInstall) { let installArgs: string[] = ["install"]; if (plan.request.packageManager === "yarn") { @@ -244,6 +275,6 @@ export const executeInitPlan = (plan: InitPlan) => }`, ); consoleService.info(chalk.bold("Next steps:")); - consoleService.info(renderNextSteps(plan)); + consoleService.info(renderNextSteps(plan, Array.from(new Set(additionalNextSteps)))); return plan; }); diff --git a/packages/cli/src/core/planInit.ts b/packages/cli/src/core/planInit.ts index 207ba535..eaa0c802 100644 --- a/packages/cli/src/core/planInit.ts +++ b/packages/cli/src/core/planInit.ts @@ -96,6 +96,7 @@ export function planInit( ], tasks: { bootstrapFileMaker: request.dataSource === "filemaker" && !request.skipFileMakerSetup, + checkWebViewerAddon: request.appType === "webviewer", runInstall: !request.noInstall, runInitialCodegen: request.dataSource === "filemaker" && diff --git a/packages/cli/src/core/types.ts b/packages/cli/src/core/types.ts index fecaf7e0..68bf7f07 100644 --- a/packages/cli/src/core/types.ts +++ b/packages/cli/src/core/types.ts @@ -124,6 +124,7 @@ export interface InitPlan { commands: Array<{ type: "install" } | { type: "codegen" } | { type: "git-init" }>; tasks: { bootstrapFileMaker: boolean; + checkWebViewerAddon: boolean; runInstall: boolean; runInitialCodegen: boolean; initializeGit: boolean; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2f4c98bb..c89cda4e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -185,6 +185,7 @@ function makeAddCommand() { "add", { name: optionalArg(textArg({ name: "name" })).pipe(withArgDescription("Component or registry item to add")), + target: optionalArg(textArg({ name: "target" })).pipe(withArgDescription("Optional add target")), noInstall: booleanOption("no-install").pipe(withOptionDescription("Skip package installation")), CI: booleanOption("ci").pipe(withOptionDescription("Deprecated alias for --non-interactive")), nonInteractive: booleanOption("non-interactive").pipe( @@ -192,7 +193,7 @@ function makeAddCommand() { ), debug: booleanOption("debug").pipe(withOptionDescription("Run in debug mode")), }, - ({ name, noInstall, CI, nonInteractive, debug }) => + ({ name, target, noInstall, CI, nonInteractive, debug }) => legacyEffect( async () => { const [{ runAdd }, { initProgramState, state }] = await Promise.all([ @@ -207,7 +208,7 @@ function makeAddCommand() { }); state.baseCommand = "add"; state.projectDir = process.cwd(); - await runAdd(getOrUndefined(name), { noInstall }); + await runAdd(getOrUndefined(name), { noInstall, target: getOrUndefined(target) }); }, { nonInteractive: CI || nonInteractive, debug }, ), diff --git a/packages/cli/src/installers/install-fm-addon.ts b/packages/cli/src/installers/install-fm-addon.ts index 384c3e16..2fd1a40a 100644 --- a/packages/cli/src/installers/install-fm-addon.ts +++ b/packages/cli/src/installers/install-fm-addon.ts @@ -6,22 +6,235 @@ import fs from "fs-extra"; import { PKG_ROOT } from "~/consts.js"; import { logger } from "~/utils/logger.js"; -export async function installFmAddon({ addonName }: { addonName: "auth" | "wv" }) { - const addonDisplayName = addonName === "auth" ? "FM Auth Add-on" : "ProofKit WebViewer"; +export type FmAddonName = "auth" | "wv"; +export type FmAddonInspectionStatus = "missing" | "installed-current" | "installed-outdated" | "unknown"; - let targetDir: string | null = null; - if (process.platform === "win32") { - targetDir = path.join(os.homedir(), "AppData", "Local", "FileMaker", "Extensions", "AddonModules"); - } else if (process.platform === "darwin") { - targetDir = path.join(os.homedir(), "Library", "Application Support", "FileMaker", "Extensions", "AddonModules"); +export interface FmAddonInspection { + status: FmAddonInspectionStatus; + addonName: FmAddonName; + addonDir: string; + addonDisplayName: string; + installCommand: string; + targetDir: string | null; + installedPath: string | null; + bundledPath: string; + bundledVersion?: string; + installedVersion?: string; + reason?: string; +} + +const FM_ADDON_VERSION_REGEX = /]*\bversion="([^"]+)"/i; +const NUMERIC_VERSION_PART_REGEX = /^\d+$/; + +function getAddonDisplayName(addonName: FmAddonName) { + return addonName === "auth" ? "FM Auth Add-on" : "ProofKit WebViewer"; +} + +function getAddonDir(addonName: FmAddonName) { + return addonName === "auth" ? "ProofKitAuth" : "ProofKitWV"; +} + +function getAddonInstallCommand(addonName: FmAddonName) { + return addonName === "auth" ? "proofkit add addon auth" : "proofkit add addon webviewer"; +} + +export function resolveFmAddonModulesDir(platform = process.platform, homeDir = os.homedir()): string | null { + const overrideDir = process.env.PROOFKIT_FM_ADDON_MODULES_DIR; + if (overrideDir) { + return overrideDir; + } + if (platform === "win32") { + return path.join(homeDir, "AppData", "Local", "FileMaker", "Extensions", "AddonModules"); + } + if (platform === "darwin") { + return path.join(homeDir, "Library", "Application Support", "FileMaker", "Extensions", "AddonModules"); + } + return null; +} + +function parseAddonVersion(version: string) { + const parts = version + .split(".") + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0 || parts.some((part) => !NUMERIC_VERSION_PART_REGEX.test(part))) { + return undefined; + } + + return parts.map((part) => Number.parseInt(part, 10)); +} + +export function compareAddonVersions(installedVersion: string, bundledVersion: string) { + const installed = parseAddonVersion(installedVersion); + const bundled = parseAddonVersion(bundledVersion); + + if (!(installed && bundled)) { + return undefined; } + const maxLength = Math.max(installed.length, bundled.length); + for (let index = 0; index < maxLength; index += 1) { + const installedPart = installed[index] ?? 0; + const bundledPart = bundled[index] ?? 0; + + if (installedPart < bundledPart) { + return -1; + } + if (installedPart > bundledPart) { + return 1; + } + } + + return 0; +} + +async function readAddonVersionFromDirectory(addonPath: string): Promise { + const templateXmlPath = path.join(addonPath, "template.xml"); + if (await fs.pathExists(templateXmlPath)) { + const templateXml = await fs.readFile(templateXmlPath, "utf8"); + const versionMatch = templateXml.match(FM_ADDON_VERSION_REGEX); + if (versionMatch?.[1]) { + return versionMatch[1]; + } + } + + const infoJsonPath = path.join(addonPath, "info.json"); + if (await fs.pathExists(infoJsonPath)) { + const infoJson = (await fs.readJson(infoJsonPath)) as { Version?: string | number }; + if (typeof infoJson.Version === "string" || typeof infoJson.Version === "number") { + return String(infoJson.Version); + } + } + + return undefined; +} + +export async function inspectFmAddon( + { + addonName, + }: { + addonName: FmAddonName; + }, + options?: { + targetDir?: string | null; + bundledPath?: string; + }, +): Promise { + const addonDir = getAddonDir(addonName); + const addonDisplayName = getAddonDisplayName(addonName); + const installCommand = getAddonInstallCommand(addonName); + const targetDir = options && "targetDir" in options ? options.targetDir : resolveFmAddonModulesDir(); + const bundledPath = options?.bundledPath ?? path.join(PKG_ROOT, `template/fm-addon/${addonDir}`); + const bundledVersion = await readAddonVersionFromDirectory(bundledPath); + + if (!targetDir) { + return { + status: "unknown", + addonName, + addonDir, + addonDisplayName, + installCommand, + targetDir: null, + installedPath: null, + bundledPath, + bundledVersion, + reason: "unsupported-platform", + }; + } + + const installedPath = path.join(targetDir, addonDir); + const installedExists = await fs.pathExists(installedPath); + + if (!installedExists) { + return { + status: "missing", + addonName, + addonDir, + addonDisplayName, + installCommand, + targetDir, + installedPath, + bundledPath, + bundledVersion, + }; + } + + const installedVersion = await readAddonVersionFromDirectory(installedPath); + if (!(installedVersion && bundledVersion)) { + return { + status: "unknown", + addonName, + addonDir, + addonDisplayName, + installCommand, + targetDir, + installedPath, + bundledPath, + bundledVersion, + installedVersion, + reason: "unreadable-version", + }; + } + + const comparison = compareAddonVersions(installedVersion, bundledVersion); + if (comparison === undefined) { + return { + status: "unknown", + addonName, + addonDir, + addonDisplayName, + installCommand, + targetDir, + installedPath, + bundledPath, + bundledVersion, + installedVersion, + reason: "invalid-version", + }; + } + + return { + status: comparison < 0 ? "installed-outdated" : "installed-current", + addonName, + addonDir, + addonDisplayName, + installCommand, + targetDir, + installedPath, + bundledPath, + bundledVersion, + installedVersion, + }; +} + +export function getFmAddonInstallInstructions(addonName: FmAddonName) { + const addonDisplayName = getAddonDisplayName(addonName); + const installCommand = getAddonInstallCommand(addonName); + + return { + addonDisplayName, + installCommand, + docsUrl: addonName === "auth" ? "https://proofkit.dev/auth/fm-addon" : "https://proofkit.dev/webviewer", + steps: [ + `Run \`${installCommand}\` to install or update the local add-on files`, + "Restart FileMaker Pro (if it's currently running)", + `Open your FileMaker file, go to layout mode, and install the ${addonDisplayName} add-on to the file`, + ], + }; +} + +export async function installFmAddonExplicitly({ addonName }: { addonName: FmAddonName }) { + const addonDisplayName = getAddonDisplayName(addonName); + + const targetDir = resolveFmAddonModulesDir(); + if (!targetDir) { logger.warn(`Could not install the ${addonDisplayName} addon. You will need to do this manually.`); - return; + return false; } - const addonDir = addonName === "auth" ? "ProofKitAuth" : "ProofKitWV"; + const addonDir = getAddonDir(addonName); await fs.copy(path.join(PKG_ROOT, `template/fm-addon/${addonDir}`), path.join(targetDir, addonDir), { overwrite: true, @@ -45,9 +258,13 @@ export async function installFmAddon({ addonName }: { addonName: "auth" | "wv" } const steps = [ "Restart FileMaker Pro (if it's currently running)", `Open your FileMaker file, go to layout mode, and install the ${addonDisplayName} addon to the file`, - "Come back here to continue the installation", ]; steps.forEach((step, index) => { console.log(`${index + 1}. ${step}`); }); + return true; +} + +export function installFmAddon({ addonName }: { addonName: FmAddonName }) { + return installFmAddonExplicitly({ addonName }); } diff --git a/packages/cli/src/installers/proofkit-webviewer.ts b/packages/cli/src/installers/proofkit-webviewer.ts index 57d5c3aa..0b868c54 100644 --- a/packages/cli/src/installers/proofkit-webviewer.ts +++ b/packages/cli/src/installers/proofkit-webviewer.ts @@ -3,14 +3,18 @@ import type { OttoAPIKey } from "@proofkit/fmdapi"; import chalk from "chalk"; import dotenv from "dotenv"; import { getLayouts } from "~/cli/fmdapi.js"; -import * as p from "~/cli/prompts.js"; -import { abortIfCancel, UserAbortedError } from "~/cli/utils.js"; import { state } from "~/state.js"; -import { getSettings } from "~/utils/parseSettings.js"; -import { installFmAddon } from "./install-fm-addon.js"; +import { readSettings } from "~/utils/parseSettings.js"; +import { type FmAddonInspection, getFmAddonInstallInstructions, inspectFmAddon } from "./install-fm-addon.js"; -export async function checkForWebViewerLayouts(): Promise { - const settings = getSettings(); +export interface WebViewerAddonStatus { + hasRequiredLayouts?: boolean; + inspection: FmAddonInspection; +} + +export async function checkForWebViewerLayouts(projectDir = state.projectDir): Promise { + const settings = readSettings(projectDir); + const inspection = await inspectFmAddon({ addonName: "wv" }); const dataSource = settings.dataSources .filter((s: { type: string }) => s.type === "fm") @@ -23,11 +27,11 @@ export async function checkForWebViewerLayouts(): Promise { | undefined; if (!dataSource) { - return false; + return { inspection }; } if (settings.envFile) { dotenv.config({ - path: path.join(state.projectDir, settings.envFile), + path: path.join(projectDir, settings.envFile), }); } const dataApiKey = process.env[dataSource.envNames.apiKey] as OttoAPIKey | undefined; @@ -35,7 +39,7 @@ export async function checkForWebViewerLayouts(): Promise { const server = process.env[dataSource.envNames.server]; if (!(dataApiKey && fmFile && server)) { - return false; + return { inspection }; } const existingLayouts = await getLayouts({ @@ -49,34 +53,81 @@ export async function checkForWebViewerLayouts(): Promise { existingLayouts.some((l: string) => l === layout), ); - if (allWebViewerLayoutsExist) { - console.log( - chalk.green("Successfully detected all required layouts for ProofKit WebViewer in your FileMaker file."), + return { + hasRequiredLayouts: allWebViewerLayoutsExist, + inspection, + }; +} + +export function getWebViewerAddonMessages({ hasRequiredLayouts, inspection }: WebViewerAddonStatus): { + info: string[]; + warn: string[]; + nextSteps: string[]; +} { + const messages = { + info: [] as string[], + warn: [] as string[], + nextSteps: [] as string[], + }; + + if (hasRequiredLayouts) { + messages.info.push("Successfully detected all required layouts for ProofKit WebViewer in your FileMaker file."); + } + + if (inspection.status === "installed-outdated") { + const versionSuffix = + inspection.installedVersion && inspection.bundledVersion + ? ` Local version: ${inspection.installedVersion}. Bundled version: ${inspection.bundledVersion}.` + : ""; + messages.warn.push( + `New ProofKit WebViewer add-on available. Run \`${inspection.installCommand}\` to update the local add-on files.${versionSuffix}`, ); - return true; + messages.nextSteps.push(inspection.installCommand); + } + + if (inspection.status === "unknown" && inspection.reason === "unsupported-platform") { + messages.warn.push("Could not inspect the local ProofKit WebViewer add-on on this platform."); } - await installFmAddon({ addonName: "wv" }); + if (hasRequiredLayouts === false) { + const instructions = getFmAddonInstallInstructions("wv"); + messages.warn.push( + "ProofKit WebViewer layouts were not detected in your FileMaker file. The add-on may not be installed in the file yet.", + ); + if (inspection.status === "missing") { + messages.warn.push( + `Local ProofKit WebViewer add-on files were not found. Run \`${inspection.installCommand}\` before installing the add-on into the FileMaker file.`, + ); + messages.nextSteps.push(inspection.installCommand); + } + if (inspection.status === "unknown" && inspection.reason !== "unsupported-platform") { + messages.warn.push( + "Could not determine the local ProofKit WebViewer add-on version. Reinstall it explicitly if you need the latest local files.", + ); + messages.nextSteps.push(inspection.installCommand); + } + messages.info.push( + chalk.bgYellow(" ACTION REQUIRED: ") + + ` Install or update the ProofKit WebViewer add-on in your FileMaker file. ${chalk.dim(`(Learn more: ${instructions.docsUrl})`)}`, + ); + for (const step of instructions.steps) { + messages.info.push(step); + } + } - return false; + return messages; } export async function ensureWebViewerAddonInstalled() { - let hasWebViewerLayouts = false; - while (!hasWebViewerLayouts) { - hasWebViewerLayouts = await checkForWebViewerLayouts(); - - if (!hasWebViewerLayouts) { - const shouldContinue = abortIfCancel( - await p.confirm({ - message: "I have followed the above instructions, continue installing", - initialValue: true, - }), - ); + const status = await checkForWebViewerLayouts(); + const messages = getWebViewerAddonMessages(status); - if (!shouldContinue) { - throw new UserAbortedError(); - } - } + for (const message of messages.warn) { + console.log(chalk.yellow(message)); } + for (const message of messages.info) { + console.log(message); + } + + return status; } diff --git a/packages/cli/src/utils/parseSettings.ts b/packages/cli/src/utils/parseSettings.ts index eb77a8ec..4424826a 100644 --- a/packages/cli/src/utils/parseSettings.ts +++ b/packages/cli/src/utils/parseSettings.ts @@ -88,13 +88,7 @@ export const defaultSettings = settingsSchema.parse({ }); let settings: Settings | undefined; -export const getSettings = () => { - if (settings) { - return settings; - } - - const settingsPath = path.join(state.projectDir, "proofkit.json"); - +function parseSettingsFile(settingsPath: string) { // Check if the settings file exists before trying to read it if (!fs.existsSync(settingsPath)) { throw new Error(`ProofKit settings file not found at: ${settingsPath}`); @@ -107,11 +101,26 @@ export const getSettings = () => { } const parsed = settingsSchema.parse(settingsFile); + return parsed; +} + +export const getSettings = () => { + if (settings) { + return settings; + } + + const settingsPath = path.join(state.projectDir, "proofkit.json"); + const parsed = parseSettingsFile(settingsPath); state.appType = parsed.appType; + settings = parsed; return parsed; }; +export function readSettings(projectDir = state.projectDir) { + return parseSettingsFile(path.join(projectDir, "proofkit.json")); +} + export type Settings = z.infer; export function mergeSettings(_settings: Partial) { diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 1b03d4d5..bae9bf57 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -91,4 +91,30 @@ describe("proofkit CLI", () => { expect(output).toContain("[debug]"); expect(output).toContain('"CommandMismatch"'); }); + + it("supports `proofkit add addon webviewer`", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-cli-addon-project-")); + const addonModulesDir = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-cli-addon-modules-")); + await fs.writeJson(path.join(cwd, "proofkit.json"), { + appType: "webviewer", + ui: "shadcn", + dataSources: [], + replacedMainPage: false, + registryTemplates: [], + }); + + const result = spawnSync("node", [distEntry, "add", "addon", "webviewer", "--non-interactive"], { + cwd, + stdio: "pipe", + encoding: "utf8", + env: { + ...process.env, + PROOFKIT_FM_ADDON_MODULES_DIR: addonModulesDir, + }, + }); + + expect(result.status).toBe(0); + + expect(await fs.pathExists(path.join(addonModulesDir, "ProofKitWV"))).toBe(true); + }); }); diff --git a/packages/cli/tests/install-fm-addon.test.ts b/packages/cli/tests/install-fm-addon.test.ts new file mode 100644 index 00000000..a721466c --- /dev/null +++ b/packages/cli/tests/install-fm-addon.test.ts @@ -0,0 +1,114 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { compareAddonVersions, inspectFmAddon } from "~/installers/install-fm-addon.js"; +import { getWebViewerAddonMessages } from "~/installers/proofkit-webviewer.js"; + +async function writeAddonVersion(dir: string, version: string) { + await fs.ensureDir(dir); + await fs.writeFile( + path.join(dir, "template.xml"), + ``, + "utf8", + ); +} + +describe("inspectFmAddon", () => { + it("returns unknown when the platform is unsupported", async () => { + const result = await inspectFmAddon( + { addonName: "wv" }, + { + targetDir: null, + bundledPath: "/tmp/bundled-addon", + }, + ); + + expect(result.status).toBe("unknown"); + expect(result.reason).toBe("unsupported-platform"); + }); + + it("returns missing when the local add-on is absent", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-addon-missing-")); + const bundledPath = path.join(root, "bundled", "ProofKitWV"); + const targetDir = path.join(root, "target"); + await writeAddonVersion(bundledPath, "2.2.3.0"); + await fs.ensureDir(targetDir); + + const result = await inspectFmAddon({ addonName: "wv" }, { targetDir, bundledPath }); + + expect(result.status).toBe("missing"); + expect(result.bundledVersion).toBe("2.2.3.0"); + }); + + it("returns installed-current when versions match", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-addon-current-")); + const bundledPath = path.join(root, "bundled", "ProofKitWV"); + const targetDir = path.join(root, "target"); + await writeAddonVersion(bundledPath, "2.2.3.0"); + await writeAddonVersion(path.join(targetDir, "ProofKitWV"), "2.2.3.0"); + + const result = await inspectFmAddon({ addonName: "wv" }, { targetDir, bundledPath }); + + expect(result.status).toBe("installed-current"); + expect(result.installedVersion).toBe("2.2.3.0"); + }); + + it("returns installed-outdated when the bundled add-on is newer", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-addon-outdated-")); + const bundledPath = path.join(root, "bundled", "ProofKitWV"); + const targetDir = path.join(root, "target"); + await writeAddonVersion(bundledPath, "2.2.4.0"); + await writeAddonVersion(path.join(targetDir, "ProofKitWV"), "2.2.3.0"); + + const result = await inspectFmAddon({ addonName: "wv" }, { targetDir, bundledPath }); + + expect(result.status).toBe("installed-outdated"); + expect(result.installedVersion).toBe("2.2.3.0"); + expect(result.bundledVersion).toBe("2.2.4.0"); + }); + + it("returns unknown when installed metadata cannot be parsed", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-addon-unknown-")); + const bundledPath = path.join(root, "bundled", "ProofKitWV"); + const targetDir = path.join(root, "target"); + await writeAddonVersion(bundledPath, "2.2.4.0"); + await fs.ensureDir(path.join(targetDir, "ProofKitWV")); + await fs.writeFile(path.join(targetDir, "ProofKitWV", "template.xml"), "", "utf8"); + + const result = await inspectFmAddon({ addonName: "wv" }, { targetDir, bundledPath }); + + expect(result.status).toBe("unknown"); + expect(result.reason).toBe("unreadable-version"); + }); +}); + +describe("compareAddonVersions", () => { + it("preserves the fourth version segment", () => { + expect(compareAddonVersions("2.2.3.0", "2.2.3.1")).toBe(-1); + expect(compareAddonVersions("2.2.3.1", "2.2.3.0")).toBe(1); + }); +}); + +describe("getWebViewerAddonMessages", () => { + it("adds an explicit update command when the local add-on is outdated", () => { + const messages = getWebViewerAddonMessages({ + hasRequiredLayouts: true, + inspection: { + status: "installed-outdated", + addonName: "wv", + addonDir: "ProofKitWV", + addonDisplayName: "ProofKit WebViewer", + installCommand: "proofkit add addon webviewer", + targetDir: "/tmp/AddonModules", + installedPath: "/tmp/AddonModules/ProofKitWV", + bundledPath: "/tmp/bundled/ProofKitWV", + installedVersion: "2.2.3.0", + bundledVersion: "2.2.4.0", + }, + }); + + expect(messages.warn.join("\n")).toContain("proofkit add addon webviewer"); + expect(messages.nextSteps).toEqual(["proofkit add addon webviewer"]); + }); +}); diff --git a/packages/cli/tests/planner.test.ts b/packages/cli/tests/planner.test.ts index 73353dd9..ba284b00 100644 --- a/packages/cli/tests/planner.test.ts +++ b/packages/cli/tests/planner.test.ts @@ -18,6 +18,7 @@ describe("planInit", () => { expect(plan.tasks.runInstall).toBe(true); expect(plan.tasks.initializeGit).toBe(true); expect(plan.tasks.bootstrapFileMaker).toBe(false); + expect(plan.tasks.checkWebViewerAddon).toBe(false); }); it("plans a webviewer scaffold with no install and no git", () => { @@ -37,6 +38,7 @@ describe("planInit", () => { expect(plan.packageJson.devDependencies["@proofkit/typegen"]).toBe("beta"); expect(plan.tasks.runInstall).toBe(false); expect(plan.tasks.initializeGit).toBe(false); + expect(plan.tasks.checkWebViewerAddon).toBe(true); }); it("plans filemaker bootstrap and initial codegen when inputs are explicit", () => {