diff --git a/packages/cli/package.json b/packages/cli/package.json index 52a022ab..4a3c78bd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "@better-fetch/fetch": "1.1.17", "@clack/core": "^0.3.5", "@clack/prompts": "^0.11.0", + "@inquirer/prompts": "^8.3.2", "@proofkit/fmdapi": "workspace:*", "@proofkit/typegen": "workspace:*", "@types/glob": "^8.1.0", diff --git a/packages/cli/src/cli/add/auth.ts b/packages/cli/src/cli/add/auth.ts index 960811ca..cab277f9 100644 --- a/packages/cli/src/cli/add/auth.ts +++ b/packages/cli/src/cli/add/auth.ts @@ -1,7 +1,7 @@ -import { cancel, select } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { z } from "zod/v4"; +import { cancel, select } from "~/cli/prompts.js"; import { addAuth } from "~/generators/auth.js"; import { ciOption, debugOption, nonInteractiveOption } from "~/globalOptions.js"; diff --git a/packages/cli/src/cli/add/data-source/deploy-demo-file.ts b/packages/cli/src/cli/add/data-source/deploy-demo-file.ts index 2154c09a..61bcebe4 100644 --- a/packages/cli/src/cli/add/data-source/deploy-demo-file.ts +++ b/packages/cli/src/cli/add/data-source/deploy-demo-file.ts @@ -1,6 +1,5 @@ -import * as p from "@clack/prompts"; - import { createDataAPIKeyWithCredentials, getDeploymentStatus, startDeployment } from "~/cli/ottofms.js"; +import * as p from "~/cli/prompts.js"; export const filename = "ProofKitDemo.fmp12"; diff --git a/packages/cli/src/cli/add/data-source/filemaker.ts b/packages/cli/src/cli/add/data-source/filemaker.ts index a00f6046..4dee4ff7 100644 --- a/packages/cli/src/cli/add/data-source/filemaker.ts +++ b/packages/cli/src/cli/add/data-source/filemaker.ts @@ -1,9 +1,8 @@ -import * as p from "@clack/prompts"; import chalk from "chalk"; import { SemVer } from "semver"; import type { z } from "zod/v4"; - import { createDataAPIKey, getOttoFMSToken, listAPIKeys, listFiles } from "~/cli/ottofms.js"; +import * as p from "~/cli/prompts.js"; import { abortIfCancel } from "~/cli/utils.js"; import { addLayout, addToFmschemaConfig, ensureWebviewerFmHttpConfig } from "~/generators/fmdapi.js"; import { getFmHttpStatus } from "~/helpers/fmHttp.js"; @@ -151,20 +150,23 @@ export async function promptForFileMakerDataSource({ fmFile = opts.fileName || abortIfCancel( - await p.select({ + await p.searchSelect({ message: `Which file would you like to connect to? ${chalk.dim("(TIP: Select the file where your data is stored)")}`, - maxItems: 10, + emptyMessage: "No matching files found.", options: [ { value: "$deployDemoFile", label: "Deploy NEW ProofKit Demo File", hint: "Use OttoFMS to deploy a new file for testing", + keywords: ["demo", "proofkit"], }, ...fileList .sort((a, b) => a.filename.localeCompare(b.filename)) .map((file) => ({ value: file.filename, label: file.filename, + hint: file.status, + keywords: [file.filename], })), ], }), @@ -178,8 +180,6 @@ export async function promptForFileMakerDataSource({ 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, }), ); @@ -212,18 +212,21 @@ export async function promptForFileMakerDataSource({ if (!dataApiKey && thisFileApiKeys.length > 0) { const selectedKey = abortIfCancel( - await p.select({ + await p.searchSelect({ message: `Which OttoFMS Data API key would you like to use? ${chalk.dim(`(This determines the access that you'll have to the data in this file)`)}`, + emptyMessage: "No matching API keys found.", options: [ ...thisFileApiKeys.map((key) => ({ value: key.key, label: `${chalk.bold(key.label)} - ${key.user}`, hint: `${key.key.slice(0, 5)}...${key.key.slice(-4)}`, + keywords: [key.label, key.user, key.database], })), { value: "create", label: "Create a new API key", hint: "Requires FileMaker credentials for this file", + keywords: ["create", "new"], }, ], }), diff --git a/packages/cli/src/cli/add/data-source/index.ts b/packages/cli/src/cli/add/data-source/index.ts index ec33ad2c..6d73789b 100644 --- a/packages/cli/src/cli/add/data-source/index.ts +++ b/packages/cli/src/cli/add/data-source/index.ts @@ -1,6 +1,6 @@ -import * as p from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod/v4"; +import * as p from "~/cli/prompts.js"; import { ensureProofKitProject } from "~/cli/utils.js"; import { ciOption, nonInteractiveOption } from "~/globalOptions.js"; diff --git a/packages/cli/src/cli/add/fmschema.ts b/packages/cli/src/cli/add/fmschema.ts index 7b9a9182..35430779 100644 --- a/packages/cli/src/cli/add/fmschema.ts +++ b/packages/cli/src/cli/add/fmschema.ts @@ -1,11 +1,11 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import type { OttoAPIKey } from "@proofkit/fmdapi"; import type { ValueListsOptions } from "@proofkit/typegen/config"; import chalk from "chalk"; import { Command } from "commander"; import dotenv from "dotenv"; import { z } from "zod/v4"; +import * as p from "~/cli/prompts.js"; import { addLayout, getExistingSchemas } from "~/generators/fmdapi.js"; import { state } from "~/state.js"; import { getSettings, type Settings } from "~/utils/parseSettings.js"; @@ -110,14 +110,15 @@ export const runAddSchemaAction = async (opts?: { const selectedLayout = passedInLayoutName ?? abortIfCancel( - await p.select({ + await p.searchSelect({ message: "Select a new layout to read data from", - maxItems: 10, + emptyMessage: "No matching layouts found.", options: layouts .filter((layout) => !existingLayouts.includes(layout)) .map((layout) => ({ label: layout, value: layout, + keywords: [layout], })), }), ); @@ -130,7 +131,6 @@ export const runAddSchemaAction = async (opts?: { message: `Enter a friendly name for the new schema.\n${chalk.dim("This will the name by which you refer to this layout in your codebase")}`, // initialValue: selectedLayout, defaultValue: defaultSchemaName, - placeholder: defaultSchemaName, validate: (input) => { if (input === "") { return; // allow empty input for the default value diff --git a/packages/cli/src/cli/add/index.ts b/packages/cli/src/cli/add/index.ts index aa15ebe8..b3e0d543 100644 --- a/packages/cli/src/cli/add/index.ts +++ b/packages/cli/src/cli/add/index.ts @@ -1,8 +1,8 @@ -import { select } from "@clack/prompts"; import type { RegistryIndex } from "@proofkit/registry"; import { Command } from "commander"; import { capitalize, groupBy, uniq } from "es-toolkit"; import ora from "ora"; +import { select } from "~/cli/prompts.js"; import { ciOption, debugOption, nonInteractiveOption } from "~/globalOptions.js"; import { initProgramState, state } from "~/state.js"; import { logger } from "~/utils/logger.js"; diff --git a/packages/cli/src/cli/add/page/index.ts b/packages/cli/src/cli/add/page/index.ts index 6ad307b4..9c0af181 100644 --- a/packages/cli/src/cli/add/page/index.ts +++ b/packages/cli/src/cli/add/page/index.ts @@ -1,11 +1,10 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { capitalize } from "es-toolkit"; import fs from "fs-extra"; - import { nextjsTemplates, wvTemplates } from "~/cli/add/page/templates.js"; +import * as p from "~/cli/prompts.js"; import { PKG_ROOT } from "~/consts.js"; import { getExistingSchemas } from "~/generators/fmdapi.js"; import { addRouteToNav } from "~/generators/route.js"; @@ -62,7 +61,6 @@ export const runAddPageAction = async (opts?: { routeName = abortIfCancel( await p.text({ message: "Enter the URL PATH for your new page", - placeholder: "/my-page", validate: (value) => { if (value.length === 0) { return "URL path is required"; @@ -224,7 +222,7 @@ async function promptForSchemaFromDataSource({ const schemaName = abortIfCancel( await p.select({ message: "Which schema should this page load data from?", - options: schemas.map((o) => ({ label: o, value: o ?? "" })), + options: schemas.map((schema) => ({ label: schema ?? "", value: schema ?? "" })), }), ); return schemaName; diff --git a/packages/cli/src/cli/add/registry/install.ts b/packages/cli/src/cli/add/registry/install.ts index 2a3aa1ea..9d7a2aef 100644 --- a/packages/cli/src/cli/add/registry/install.ts +++ b/packages/cli/src/cli/add/registry/install.ts @@ -1,8 +1,8 @@ -import * as p from "@clack/prompts"; import { getOtherProofKitDependencies } from "@proofkit/registry"; import { capitalize, uniq } from "es-toolkit"; import ora from "ora"; import semver from "semver"; +import * as p from "~/cli/prompts.js"; import { abortIfCancel } from "~/cli/utils.js"; import { getExistingSchemas } from "~/generators/fmdapi.js"; @@ -32,7 +32,7 @@ async function promptForSchemaFromDataSource({ dataSourceName: dataSource.name, }) .map((s) => s.schemaName) - .filter(Boolean); + .filter((schemaName): schemaName is string => Boolean(schemaName)); if (schemas.length === 0) { p.cancel("This data source doesn't have any schemas to load data from"); @@ -46,7 +46,7 @@ async function promptForSchemaFromDataSource({ const schemaName = abortIfCancel( await p.select({ message: "Which schema should this template use?", - options: schemas.map((o) => ({ label: o, value: o ?? "" })), + options: schemas.map((schema) => ({ label: schema, value: schema })), }), ); return schemaName; @@ -125,7 +125,6 @@ export async function installFromRegistry(name: string) { routeName = abortIfCancel( await p.text({ message: "Enter the URL PATH for your new page", - placeholder: "/my-page", validate: (value) => { if (value.length === 0) { return "URL path is required"; diff --git a/packages/cli/src/cli/deploy/index.ts b/packages/cli/src/cli/deploy/index.ts index dd976ae8..ecc1deac 100644 --- a/packages/cli/src/cli/deploy/index.ts +++ b/packages/cli/src/cli/deploy/index.ts @@ -1,10 +1,10 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import { Command, Option } from "commander"; import { execa } from "execa"; import fs from "fs-extra"; import type { PackageJson } from "type-fest"; +import * as p from "~/cli/prompts.js"; import { ciOption, debugOption } from "~/globalOptions.js"; diff --git a/packages/cli/src/cli/init.ts b/packages/cli/src/cli/init.ts index d7c0d577..0196194b 100644 --- a/packages/cli/src/cli/init.ts +++ b/packages/cli/src/cli/init.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { select, text } from "@clack/prompts"; import { Command } from "commander"; import { execa } from "execa"; import fs from "fs-extra"; @@ -22,6 +21,7 @@ import { parseNameAndPath } from "~/utils/parseNameAndPath.js"; import { type Settings, setSettings } from "~/utils/parseSettings.js"; import { validateAppName } from "~/utils/validateAppName.js"; import { promptForFileMakerDataSource } from "./add/data-source/filemaker.js"; +import { select, text } from "./prompts.js"; import { abortIfCancel } from "./utils.js"; interface CliFlags { diff --git a/packages/cli/src/cli/menu.ts b/packages/cli/src/cli/menu.ts index cab1dd4c..be40d6a7 100644 --- a/packages/cli/src/cli/menu.ts +++ b/packages/cli/src/cli/menu.ts @@ -1,6 +1,6 @@ -import { confirm, log, select } from "@clack/prompts"; import chalk from "chalk"; import open from "open"; +import { confirm, log, select } from "~/cli/prompts.js"; import { DOCS_URL } from "~/consts.js"; import { checkForAvailableUpgrades, runAllAvailableUpgrades } from "~/upgrades/index.js"; diff --git a/packages/cli/src/cli/ottofms.ts b/packages/cli/src/cli/ottofms.ts index 58455a77..e80ad3bd 100644 --- a/packages/cli/src/cli/ottofms.ts +++ b/packages/cli/src/cli/ottofms.ts @@ -1,10 +1,10 @@ -import * as clack from "@clack/prompts"; import axios, { AxiosError } from "axios"; import chalk from "chalk"; import open from "open"; import randomstring from "randomstring"; import { z } from "zod/v4"; +import * as clack from "~/cli/prompts.js"; import { abortIfCancel } from "./utils.js"; interface WizardResponse { @@ -181,8 +181,6 @@ ${url.origin}/otto/app/api-keys`, const tryAgain = abortIfCancel( await clack.confirm({ message: "Do you want to try and enter credentials again?", - active: "Yes, try again", - inactive: "No, abort", }), ); if (!tryAgain) { diff --git a/packages/cli/src/cli/prompts.ts b/packages/cli/src/cli/prompts.ts new file mode 100644 index 00000000..ea4bc1b2 --- /dev/null +++ b/packages/cli/src/cli/prompts.ts @@ -0,0 +1,186 @@ +import * as clack from "@clack/prompts"; +import { + checkbox as inquirerCheckbox, + confirm as inquirerConfirm, + input as inquirerInput, + password as inquirerPassword, + search as inquirerSearch, + select as inquirerSelect, +} from "@inquirer/prompts"; + +const CANCEL_SYMBOL = Symbol.for("@proofkit/cli/prompt-cancelled"); + +export const intro = clack.intro; +export const outro = clack.outro; +export const note = clack.note; +export const log = clack.log; +export const spinner = clack.spinner; +export const cancel = clack.cancel; + +export interface PromptOption { + value: T; + label: string; + hint?: string; + disabled?: boolean | string; +} + +export interface SearchPromptOption extends PromptOption { + keywords?: readonly string[]; +} + +function normalizeValidate( + validate: ((value: string) => string | undefined) | undefined, +): ((value: string) => string | boolean) | undefined { + if (!validate) { + return undefined; + } + + return (value: string) => validate(value) ?? true; +} + +function normalizeDisabledMessage(value: boolean | string | undefined) { + if (typeof value === "string") { + return value; + } + return value ? true : undefined; +} + +function isPromptCancel(error: unknown) { + return error instanceof Error && error.name === "ExitPromptError"; +} + +function withCancelSentinel(fn: () => Promise): Promise { + return fn().catch((error: unknown) => { + if (isPromptCancel(error)) { + return CANCEL_SYMBOL; + } + throw error; + }); +} + +export function isCancel(value: unknown): value is symbol { + return value === CANCEL_SYMBOL || clack.isCancel(value); +} + +function matchesSearch(option: SearchPromptOption, query: string) { + const haystack = [option.label, option.hint ?? "", ...(option.keywords ?? [])].join(" ").toLowerCase(); + return haystack.includes(query.trim().toLowerCase()); +} + +export function filterSearchOptions( + options: readonly SearchPromptOption[], + query: string | undefined, +) { + const term = query?.trim(); + if (!term) { + return options; + } + + return options.filter((option) => matchesSearch(option, term)); +} + +export function text(options: { + message: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; +}) { + return withCancelSentinel(() => + inquirerInput({ + message: options.message, + default: options.defaultValue, + validate: normalizeValidate(options.validate), + }), + ); +} + +export function password(options: { message: string; validate?: (value: string) => string | undefined }) { + return withCancelSentinel(() => + inquirerPassword({ + message: options.message, + validate: normalizeValidate(options.validate), + }), + ); +} + +export function confirm(options: { message: string; initialValue?: boolean }) { + return withCancelSentinel( + () => + inquirerConfirm({ + message: options.message, + default: options.initialValue, + }) as Promise, + ); +} + +export function select(options: { + message: string; + options: PromptOption[]; + maxItems?: number; + initialValue?: T; +}) { + return withCancelSentinel(() => + inquirerSelect({ + message: options.message, + pageSize: options.maxItems ?? 10, + default: options.initialValue, + choices: options.options.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })), + }), + ); +} + +export function searchSelect(options: { + message: string; + emptyMessage?: string; + options: SearchPromptOption[]; +}) { + return withCancelSentinel(() => + inquirerSearch({ + message: options.message, + pageSize: 10, + source: (input) => { + const filtered = filterSearchOptions(options.options, input); + if (filtered.length === 0) { + return [ + { + value: "__no_matches__" as T, + name: options.emptyMessage ?? "No matches found. Keep typing to refine your search.", + disabled: options.emptyMessage ?? "No matches found", + }, + ]; + } + + return filtered.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })); + }, + }), + ); +} + +export function multiSearchSelect(options: { + message: string; + options: SearchPromptOption[]; + required?: boolean; +}) { + return withCancelSentinel(() => + inquirerCheckbox({ + message: options.message, + pageSize: 10, + required: options.required, + choices: options.options.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })), + }), + ); +} diff --git a/packages/cli/src/cli/react-email.ts b/packages/cli/src/cli/react-email.ts index bc852726..f0ac245a 100644 --- a/packages/cli/src/cli/react-email.ts +++ b/packages/cli/src/cli/react-email.ts @@ -1,5 +1,5 @@ -import * as p from "@clack/prompts"; import { Command, Option } from "commander"; +import * as p from "~/cli/prompts.js"; import { installReactEmail } from "~/installers/react-email.js"; diff --git a/packages/cli/src/cli/remove/data-source.ts b/packages/cli/src/cli/remove/data-source.ts index 0bafd0e1..dbc6aebf 100644 --- a/packages/cli/src/cli/remove/data-source.ts +++ b/packages/cli/src/cli/remove/data-source.ts @@ -1,9 +1,9 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import { Command } from "commander"; import dotenv from "dotenv"; import fs from "fs-extra"; import { z } from "zod/v4"; +import * as p from "~/cli/prompts.js"; import { removeFromFmschemaConfig, runCodegenCommand } from "~/generators/fmdapi.js"; import { ciOption, debugOption, nonInteractiveOption } from "~/globalOptions.js"; diff --git a/packages/cli/src/cli/remove/index.ts b/packages/cli/src/cli/remove/index.ts index 4d58cbca..954e8765 100644 --- a/packages/cli/src/cli/remove/index.ts +++ b/packages/cli/src/cli/remove/index.ts @@ -1,5 +1,5 @@ -import * as p from "@clack/prompts"; import { Command } from "commander"; +import * as p from "~/cli/prompts.js"; import { ciOption, debugOption } from "~/globalOptions.js"; import { initProgramState, state } from "~/state.js"; diff --git a/packages/cli/src/cli/remove/page.ts b/packages/cli/src/cli/remove/page.ts index e437bf45..d6574589 100644 --- a/packages/cli/src/cli/remove/page.ts +++ b/packages/cli/src/cli/remove/page.ts @@ -1,8 +1,8 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import { Command } from "commander"; import fs from "fs-extra"; import { Node, type Project, type PropertyAssignment, SyntaxKind } from "ts-morph"; +import * as p from "~/cli/prompts.js"; import { ciOption, debugOption } from "~/globalOptions.js"; import { initProgramState, state } from "~/state.js"; diff --git a/packages/cli/src/cli/remove/schema.ts b/packages/cli/src/cli/remove/schema.ts index 21c4a4ae..4cc40088 100644 --- a/packages/cli/src/cli/remove/schema.ts +++ b/packages/cli/src/cli/remove/schema.ts @@ -1,6 +1,6 @@ -import * as p from "@clack/prompts"; import { Command } from "commander"; import { z } from "zod/v4"; +import * as p from "~/cli/prompts.js"; import { getExistingSchemas, removeLayout } from "~/generators/fmdapi.js"; import { state } from "~/state.js"; diff --git a/packages/cli/src/cli/tanstack-query.ts b/packages/cli/src/cli/tanstack-query.ts index ea99887c..fb29fac0 100644 --- a/packages/cli/src/cli/tanstack-query.ts +++ b/packages/cli/src/cli/tanstack-query.ts @@ -1,5 +1,5 @@ -import * as p from "@clack/prompts"; import { Command } from "commander"; +import * as p from "~/cli/prompts.js"; import { injectTanstackQuery } from "~/generators/tanstack-query.js"; diff --git a/packages/cli/src/cli/utils.ts b/packages/cli/src/cli/utils.ts index 5bc0dcd7..37a6897f 100644 --- a/packages/cli/src/cli/utils.ts +++ b/packages/cli/src/cli/utils.ts @@ -1,11 +1,9 @@ -// import { isCancel } from "@clack/core"; - import path from "node:path"; -import { cancel, isCancel } from "@clack/prompts"; import chalk from "chalk"; import fs from "fs-extra"; import z, { ZodError } from "zod/v4"; +import { cancel, isCancel } from "~/cli/prompts.js"; import { npmName } from "~/consts.js"; import { getSettings } from "~/utils/parseSettings.js"; diff --git a/packages/cli/src/consts.ts b/packages/cli/src/consts.ts index 8d0c8e82..8c9ba3ed 100644 --- a/packages/cli/src/consts.ts +++ b/packages/cli/src/consts.ts @@ -36,6 +36,4 @@ declare const __REGISTRY_URL__: string; // Provide a safe fallback when running from source (not built) export const DEFAULT_REGISTRY_URL = // typeof check avoids ReferenceError if not defined at runtime - typeof __REGISTRY_URL__ !== "undefined" && __REGISTRY_URL__ - ? __REGISTRY_URL__ - : "https://proofkit.dev"; + typeof __REGISTRY_URL__ !== "undefined" && __REGISTRY_URL__ ? __REGISTRY_URL__ : "https://proofkit.dev"; diff --git a/packages/cli/src/helpers/git.ts b/packages/cli/src/helpers/git.ts index 2819db93..bdeaefee 100644 --- a/packages/cli/src/helpers/git.ts +++ b/packages/cli/src/helpers/git.ts @@ -1,10 +1,10 @@ import { execSync } from "node:child_process"; import path from "node:path"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import { execa } from "execa"; import fs from "fs-extra"; import ora from "ora"; +import * as p from "~/cli/prompts.js"; import { isNonInteractiveMode } from "~/state.js"; import { logger } from "~/utils/logger.js"; diff --git a/packages/cli/src/helpers/logNextSteps.ts b/packages/cli/src/helpers/logNextSteps.ts index c8b5850e..b4df3059 100644 --- a/packages/cli/src/helpers/logNextSteps.ts +++ b/packages/cli/src/helpers/logNextSteps.ts @@ -6,13 +6,8 @@ import { state } from "~/state.js"; import { getUserPkgManager } from "~/utils/getUserPkgManager.js"; import { logger } from "~/utils/logger.js"; -const formatRunCommand = ( - pkgManager: ReturnType, - command: string, -) => - ["npm", "bun"].includes(pkgManager) - ? `${pkgManager} run ${command}` - : `${pkgManager} ${command}`; +const formatRunCommand = (pkgManager: ReturnType, command: string) => + ["npm", "bun"].includes(pkgManager) ? `${pkgManager} run ${command}` : `${pkgManager} ${command}`; // This logs the next steps that the user should take in order to advance the project export const logNextSteps = ({ @@ -24,9 +19,7 @@ export const logNextSteps = ({ logger.info(chalk.bold("Next steps:")); logger.dim("\nNavigate to the project directory:"); projectName !== "." && logger.info(` cd ${projectName}`); - logger.dim( - "(or open in your code editor, and run the rest of these commands from there)", - ); + logger.dim("(or open in your code editor, and run the rest of these commands from there)"); if (noInstall) { logger.dim("\nInstall dependencies:"); @@ -38,9 +31,7 @@ export const logNextSteps = ({ } } - logger.dim( - "\nAgent instruction (required before continuing any AI-assisted development in this project):", - ); + logger.dim("\nAgent instruction (required before continuing any AI-assisted development in this project):"); logger.info(" npx @tanstack/intent@latest install"); logger.dim( " Have your agent run this command in the newly created project so it can discover and learn important skills relevant to the project.", @@ -53,15 +44,11 @@ export const logNextSteps = ({ logger.dim("\nWhen you're ready to generate FileMaker clients:"); logger.info(` ${formatRunCommand(pkgManager, "typegen")}`); - logger.dim( - "\nTo open the starter inside FileMaker once your file is ready:", - ); + logger.dim("\nTo open the starter inside FileMaker once your file is ready:"); logger.info(` ${formatRunCommand(pkgManager, "launch-fm")}`); } - logger.dim( - "\nOr, run the ProofKit command again to add more to your project:", - ); + logger.dim("\nOr, run the ProofKit command again to add more to your project:"); logger.info(` ${formatRunCommand(pkgManager, "proofkit")}`); logger.dim("(Must be inside the project directory)"); }; diff --git a/packages/cli/src/helpers/scaffoldProject.ts b/packages/cli/src/helpers/scaffoldProject.ts index b4ac8c4d..7905eb0a 100644 --- a/packages/cli/src/helpers/scaffoldProject.ts +++ b/packages/cli/src/helpers/scaffoldProject.ts @@ -1,8 +1,8 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import fs from "fs-extra"; import ora from "ora"; +import * as p from "~/cli/prompts.js"; import { PKG_ROOT } from "~/consts.js"; import type { InstallerOptions } from "~/installers/index.js"; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8b7525c1..91a32558 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node --no-warnings -import { intro } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { makeInitCommand, runInit } from "~/cli/init.js"; +import { intro } from "~/cli/prompts.js"; import { logger } from "~/utils/logger.js"; import { proofGradient, renderTitle } from "~/utils/renderTitle.js"; import { makeAddCommand } from "./cli/add/index.js"; diff --git a/packages/cli/src/installers/proofkit-auth.ts b/packages/cli/src/installers/proofkit-auth.ts index 7099bcca..26e6ce7d 100644 --- a/packages/cli/src/installers/proofkit-auth.ts +++ b/packages/cli/src/installers/proofkit-auth.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import type { OttoAPIKey } from "@proofkit/fmdapi"; import chalk from "chalk"; import dotenv from "dotenv"; @@ -7,6 +6,7 @@ import fs from "fs-extra"; import ora, { type Ora } from "ora"; import { type SourceFile, SyntaxKind } from "ts-morph"; import { getLayouts } from "~/cli/fmdapi.js"; +import * as p from "~/cli/prompts.js"; import { abortIfCancel, UserAbortedError } from "~/cli/utils.js"; import { PKG_ROOT } from "~/consts.js"; import { addConfig, runCodegenCommand } from "~/generators/fmdapi.js"; @@ -113,8 +113,6 @@ export const proofkitAuthInstaller = async () => { await p.confirm({ message: "I have followed the above instructions, continue installing", initialValue: true, - active: "Continue", - inactive: "Abort", }), ); diff --git a/packages/cli/src/installers/proofkit-webviewer.ts b/packages/cli/src/installers/proofkit-webviewer.ts index 345f6110..57d5c3aa 100644 --- a/packages/cli/src/installers/proofkit-webviewer.ts +++ b/packages/cli/src/installers/proofkit-webviewer.ts @@ -1,10 +1,9 @@ import path from "node:path"; -import * as p from "@clack/prompts"; 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"; @@ -72,8 +71,6 @@ export async function ensureWebViewerAddonInstalled() { await p.confirm({ message: "I have followed the above instructions, continue installing", initialValue: true, - active: "Continue", - inactive: "Abort", }), ); diff --git a/packages/cli/src/installers/react-email.ts b/packages/cli/src/installers/react-email.ts index 401fe6e0..9e59d2f6 100644 --- a/packages/cli/src/installers/react-email.ts +++ b/packages/cli/src/installers/react-email.ts @@ -1,9 +1,9 @@ import path from "node:path"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import fs from "fs-extra"; import type { Project } from "ts-morph"; import type { PackageJson } from "type-fest"; +import * as p from "~/cli/prompts.js"; import { abortIfCancel } from "~/cli/utils.js"; import { PKG_ROOT } from "~/consts.js"; @@ -121,7 +121,6 @@ export async function installPlunk({ project }: { project?: Project }) { message: `Enter your Plunk API key\n${chalk.dim( "Enter your Secret API Key from https://app.useplunk.com/settings/api", )}`, - placeholder: "...or leave blank to do this later", }), ); } @@ -175,7 +174,6 @@ export async function installResend({ project }: { project?: Project }) { message: `Enter your Resend API key\n${chalk.dim( `Only "Sending Access" permission required: https://resend.com/api-keys`, )}`, - placeholder: "...or leave blank to do this later", }), ); } diff --git a/packages/cli/src/utils/renderVersionWarning.ts b/packages/cli/src/utils/renderVersionWarning.ts index 28c3bbbc..fd046831 100644 --- a/packages/cli/src/utils/renderVersionWarning.ts +++ b/packages/cli/src/utils/renderVersionWarning.ts @@ -1,8 +1,8 @@ import { execSync } from "node:child_process"; import https from "node:https"; -import * as p from "@clack/prompts"; import chalk from "chalk"; import * as semver from "semver"; +import * as p from "~/cli/prompts.js"; import { cliName, npmName } from "~/consts.js"; import { getVersion } from "./getProofKitVersion.js"; diff --git a/packages/new/package.json b/packages/new/package.json new file mode 100644 index 00000000..6851ea3d --- /dev/null +++ b/packages/new/package.json @@ -0,0 +1,65 @@ +{ + "name": "@proofkit/new", + "version": "0.0.0-private", + "private": true, + "description": "Internal scaffold package for the next ProofKit CLI", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "bin": { + "proofkit-new": "dist/index.js" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "engines": { + "node": "^20.0.0 || ^22.0.0" + }, + "scripts": { + "typecheck": "tsc", + "build": "NODE_ENV=production tsdown", + "dev": "tsdown --watch", + "clean": "rm -rf dist .turbo node_modules", + "start": "node dist/index.js", + "lint": "biome check . --write", + "lint:summary": "biome check . --reporter=summary", + "test": "vitest run" + }, + "dependencies": { + "@clack/prompts": "^0.11.0", + "@effect/cli": "0.74.0", + "@effect/platform": "0.95.0", + "@effect/platform-node": "0.105.0", + "@effect/printer": "0.48.0", + "@effect/printer-ansi": "0.48.0", + "@inquirer/prompts": "^8.3.2", + "axios": "^1.13.2", + "chalk": "5.4.1", + "effect": "^3.20.0", + "execa": "^9.6.1", + "fs-extra": "^11.3.3", + "gradient-string": "^2.0.2", + "jsonc-parser": "^3.3.1", + "open": "^10.2.0", + "ora": "6.3.1", + "sort-package-json": "^2.15.1", + "type-fest": "^3.13.1" + }, + "devDependencies": { + "@biomejs/biome": "2.3.11", + "@types/fs-extra": "^11.0.4", + "@types/gradient-string": "^1.1.6", + "@types/node": "^22.19.5", + "@vitest/coverage-v8": "^2.1.9", + "tsdown": "^0.14.2", + "typescript": "^5.9.3", + "ultracite": "7.0.8", + "vitest": "^4.0.17" + } +} diff --git a/packages/new/src/consts.ts b/packages/new/src/consts.ts new file mode 100644 index 00000000..2f5676a5 --- /dev/null +++ b/packages/new/src/consts.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const distPath = path.dirname(__filename); +export const PKG_ROOT = path.join(distPath, "../"); + +export const DEFAULT_APP_NAME = "my-proofkit-app"; +export const cliName = "proofkit-new"; +const TITLE_ASCII = ` + _______ ___ ___ ____ _ _ +|_ __ \\ .' ..]|_ ||_ _| (_) / |_ + | |__) |_ .--. .--. .--. _| |_ | |_/ / __ \`| |-' + | ___/[ \`/'\`\\]/ .'\`\\ \\/ .'\`\\ \\'-| |-' | __'. [ | | | + _| |_ | | | \\__. || \\__. | | | _| | \\ \\_ | | | |, +|_____| [___] '.__.' '.__.' [___] |____||____|[___]\\__/ +`; + +export function getTitleText(version: string) { + const versionText = `v${version}`; + const lineWidth = 61; + const padding = Math.max(lineWidth - versionText.length, 0); + return `${TITLE_ASCII}${" ".repeat(padding)}${versionText}\n`; +} + +function resolveTemplateRoot(): string { + const candidates = [path.join(PKG_ROOT, "template"), path.resolve(PKG_ROOT, "../cli/template")] as const; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + throw new Error(`Could not locate a template directory. Checked: ${candidates.join(", ")}`); +} + +export const TEMPLATE_ROOT = resolveTemplateRoot(); diff --git a/packages/new/src/core/context.ts b/packages/new/src/core/context.ts new file mode 100644 index 00000000..abfa8887 --- /dev/null +++ b/packages/new/src/core/context.ts @@ -0,0 +1,208 @@ +import { Context } from "effect"; +import type { AppType, FileMakerEnvNames, FileMakerInputs, ProofKitSettings, UIType } from "~/core/types.js"; +import type { PackageManager } from "~/utils/packageManager.js"; + +export interface CliContextValue { + cwd: string; + debug: boolean; + nonInteractive: boolean; + packageManager: PackageManager; + resolvedProjectConfig?: { + appType?: AppType; + ui?: UIType; + projectDir?: string; + }; +} + +export const CliContext = Context.GenericTag("@proofkit/new/CliContext"); + +export interface PromptService { + readonly text: (options: { + message: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; + }) => Promise; + readonly password: (options: { + message: string; + validate?: (value: string) => string | undefined; + }) => Promise; + readonly select: (options: { + message: string; + options: Array<{ value: T; label: string; hint?: string; disabled?: boolean | string }>; + }) => Promise; + readonly searchSelect: (options: { + message: string; + emptyMessage?: string; + options: Array<{ value: T; label: string; hint?: string; keywords?: string[]; disabled?: boolean | string }>; + }) => Promise; + readonly multiSearchSelect: (options: { + message: string; + options: Array<{ value: T; label: string; hint?: string; keywords?: string[]; disabled?: boolean | string }>; + required?: boolean; + }) => Promise; + readonly confirm: (options: { message: string; initialValue?: boolean }) => Promise; +} + +export const PromptService = Context.GenericTag("@proofkit/new/PromptService"); + +export interface ConsoleService { + readonly info: (message: string) => void; + readonly warn: (message: string) => void; + readonly error: (message: string) => void; + readonly success: (message: string) => void; + readonly note: (message: string, title?: string) => void; +} + +export const ConsoleService = Context.GenericTag("@proofkit/new/ConsoleService"); + +export interface FileSystemService { + readonly exists: (path: string) => Promise; + readonly readdir: (path: string) => Promise; + readonly emptyDir: (path: string) => Promise; + readonly copyDir: (from: string, to: string, options?: { overwrite?: boolean }) => Promise; + readonly rename: (from: string, to: string) => Promise; + readonly remove: (path: string) => Promise; + readonly readJson: (path: string) => Promise; + readonly writeJson: (path: string, value: unknown) => Promise; + readonly writeFile: (path: string, content: string) => Promise; + readonly readFile: (path: string) => Promise; +} + +export const FileSystemService = Context.GenericTag("@proofkit/new/FileSystemService"); + +export interface TemplateService { + readonly getTemplateDir: (appType: AppType, ui: UIType) => string; +} + +export const TemplateService = Context.GenericTag("@proofkit/new/TemplateService"); + +export interface PackageManagerService { + readonly getVersion: (packageManager: PackageManager, cwd: string) => Promise; +} + +export const PackageManagerService = Context.GenericTag("@proofkit/new/PackageManagerService"); + +export interface ProcessService { + readonly run: ( + command: string, + args: string[], + options: { + cwd: string; + stdout?: "pipe" | "inherit" | "ignore"; + stderr?: "pipe" | "inherit" | "ignore"; + }, + ) => Promise<{ stdout: string; stderr: string }>; +} + +export const ProcessService = Context.GenericTag("@proofkit/new/ProcessService"); + +export interface GitService { + readonly initialize: (projectDir: string) => Promise; +} + +export const GitService = Context.GenericTag("@proofkit/new/GitService"); + +export interface SettingsService { + readonly writeSettings: (projectDir: string, settings: ProofKitSettings) => Promise; + readonly appendEnvVars: (projectDir: string, vars: Record) => Promise; + readonly ensureTypegenConfig: ( + projectDir: string, + options: { appType: AppType; fileMaker?: FileMakerInputs }, + ) => Promise; +} + +export const SettingsService = Context.GenericTag("@proofkit/new/SettingsService"); + +export interface FmHttpStatus { + baseUrl: string; + healthy: boolean; + connectedFiles: string[]; +} + +export interface FileMakerServerVersions { + fmsVersion: string; + ottoVersion: string | null; +} + +export interface OttoFileInfo { + filename: string; + status: string; +} + +export interface OttoApiKeyInfo { + key: string; + user: string; + database: string; + label: string; +} + +export interface FileMakerDataSourceEntry { + type: "fm"; + name: string; + envNames: FileMakerEnvNames; +} + +export interface FileMakerBootstrapArtifacts { + settings: ProofKitSettings; + envVars: Record; + envSchemaEntries: Array<{ + name: string; + zodSchema: string; + defaultValue: string; + }>; + typegenConfig: { + mode: FileMakerInputs["mode"]; + dataSourceName: string; + envNames?: FileMakerEnvNames; + fmHttpBaseUrl?: string; + connectedFileName?: string; + layoutName?: string; + schemaName?: string; + appType: AppType; + }; +} + +export interface FileMakerService { + readonly detectLocalFmHttp: (baseUrl?: string) => Promise; + readonly validateHostedServerUrl: ( + serverUrl: string, + ottoPort?: number | null, + ) => Promise<{ + normalizedUrl: string; + versions: FileMakerServerVersions; + }>; + readonly getOttoFMSToken: (options: { url: URL }) => Promise<{ token: string }>; + readonly listFiles: (options: { url: URL; token: string }) => Promise; + readonly listAPIKeys: (options: { url: URL; token: string }) => Promise; + readonly createDataAPIKeyWithCredentials: (options: { + url: URL; + filename: string; + username: string; + password: string; + }) => Promise<{ apiKey: string }>; + readonly deployDemoFile: (options: { + url: URL; + token: string; + operation: "install" | "replace"; + }) => Promise<{ apiKey: string; filename: string }>; + readonly listLayouts: (options: { dataApiKey: string; fmFile: string; server: string }) => Promise; + readonly createFileMakerBootstrapArtifacts: ( + settings: ProofKitSettings, + inputs: FileMakerInputs, + appType: AppType, + ) => Promise; + readonly bootstrap: ( + projectDir: string, + settings: ProofKitSettings, + inputs: FileMakerInputs, + appType: AppType, + ) => Promise; +} + +export const FileMakerService = Context.GenericTag("@proofkit/new/FileMakerService"); + +export interface CodegenService { + readonly runInitial: (projectDir: string, packageManager: PackageManager) => Promise; +} + +export const CodegenService = Context.GenericTag("@proofkit/new/CodegenService"); diff --git a/packages/new/src/core/errors.ts b/packages/new/src/core/errors.ts new file mode 100644 index 00000000..54ad4405 --- /dev/null +++ b/packages/new/src/core/errors.ts @@ -0,0 +1,6 @@ +export class UserAbortedError extends Error { + constructor(message = "User aborted the operation") { + super(message); + this.name = "UserAbortedError"; + } +} diff --git a/packages/new/src/core/executeInitPlan.ts b/packages/new/src/core/executeInitPlan.ts new file mode 100644 index 00000000..104df9cd --- /dev/null +++ b/packages/new/src/core/executeInitPlan.ts @@ -0,0 +1,200 @@ +import path from "node:path"; +import { Effect } from "effect"; +import sortPackageJson from "sort-package-json"; + +import { + CliContext, + CodegenService, + ConsoleService, + FileMakerService, + FileSystemService, + GitService, + PackageManagerService, + ProcessService, + PromptService, + SettingsService, +} from "~/core/context.js"; +import { UserAbortedError } from "~/core/errors.js"; +import { applyPackageJsonMutations } from "~/core/planInit.js"; +import type { InitPlan } from "~/core/types.js"; +import { normalizeImportAlias, replaceTextInFiles } from "~/utils/projectFiles.js"; + +const AGENT_METADATA_DIRS = new Set([".agents", ".claude", ".clawed", ".clinerules", ".cursor", ".windsurf"]); +const IMPORT_ALIAS_WILDCARD_REGEX = /\*/g; +const IMPORT_ALIAS_TRAILING_SLASH_REGEX = /\/?$/; + +function getMeaningfulDirectoryEntries(entries: string[]) { + return entries.filter((entry) => { + if (AGENT_METADATA_DIRS.has(entry)) { + return false; + } + if (entry === ".gitignore") { + return true; + } + if (entry.startsWith(".")) { + return false; + } + return true; + }); +} + +export const prepareDirectory = (plan: InitPlan) => + Effect.gen(function* () { + const fs = yield* FileSystemService; + const consoleService = yield* ConsoleService; + const cliContext = yield* CliContext; + const prompts = yield* PromptService; + + const exists = yield* Effect.promise(() => fs.exists(plan.targetDir)); + if (!exists) { + return; + } + + const entries = yield* Effect.promise(() => fs.readdir(plan.targetDir)); + const meaningfulEntries = getMeaningfulDirectoryEntries(entries); + if (meaningfulEntries.length === 0) { + return; + } + + if (plan.request.force) { + yield* Effect.promise(() => fs.emptyDir(plan.targetDir)); + return; + } + + if (cliContext.nonInteractive) { + throw new Error( + `${plan.request.appDir} already exists and isn't empty. Remove the existing files or choose a different directory.`, + ); + } + + const overwriteMode = yield* Effect.promise(() => + prompts.select({ + message: `${plan.request.appDir} already exists and isn't empty. How would you like to proceed?`, + options: [ + { value: "abort", label: "Abort installation" }, + { value: "clear", label: "Clear the directory and continue" }, + { value: "overwrite", label: "Continue and overwrite conflicting files" }, + ], + }), + ); + + if (overwriteMode === "abort") { + throw new UserAbortedError(); + } + + if (overwriteMode === "clear") { + const confirmed = yield* Effect.promise(() => + prompts.confirm({ + message: "Are you sure you want to clear the directory?", + initialValue: false, + }), + ); + if (!confirmed) { + throw new UserAbortedError(); + } + yield* Effect.promise(() => fs.emptyDir(plan.targetDir)); + return; + } + + consoleService.warn(`Continuing in ${plan.request.appDir} and overwriting conflicting files when needed.`); + }); + +export const executeInitPlan = (plan: InitPlan) => + Effect.gen(function* () { + const fs = yield* FileSystemService; + const consoleService = yield* ConsoleService; + const settingsService = yield* SettingsService; + const fileMakerService = yield* FileMakerService; + const processService = yield* ProcessService; + const gitService = yield* GitService; + const codegenService = yield* CodegenService; + const packageManagerService = yield* PackageManagerService; + + yield* prepareDirectory(plan); + + consoleService.info(`Scaffolding in ${plan.targetDir}`); + yield* Effect.promise(() => fs.copyDir(plan.templateDir, plan.targetDir, { overwrite: true })); + + const stagedGitignore = path.join(plan.targetDir, "_gitignore"); + const finalGitignore = path.join(plan.targetDir, ".gitignore"); + if (yield* Effect.promise(() => fs.exists(stagedGitignore))) { + if (yield* Effect.promise(() => fs.exists(finalGitignore))) { + yield* Effect.promise(() => fs.remove(stagedGitignore)); + } else { + yield* Effect.promise(() => fs.rename(stagedGitignore, finalGitignore)); + } + } + + const packageJsonPath = path.join(plan.targetDir, "package.json"); + const packageJson = yield* Effect.promise(() => fs.readJson>(packageJsonPath)); + const updatedPackageJson = sortPackageJson( + applyPackageJsonMutations(packageJson as never, plan.packageJson) as never, + ); + yield* Effect.promise(() => fs.writeJson(packageJsonPath, updatedPackageJson)); + + yield* Effect.promise(() => settingsService.writeSettings(plan.targetDir, plan.settings)); + yield* Effect.promise(() => fs.writeFile(plan.envFile.path, plan.envFile.content)); + for (const write of plan.writes) { + yield* Effect.promise(() => fs.writeFile(write.path, write.content)); + } + + yield* Effect.promise(() => replaceTextInFiles(fs, plan.targetDir, "__PNPM_COMMAND__", plan.packageManagerCommand)); + if (plan.request.importAlias !== "~/") { + yield* Effect.promise(() => + replaceTextInFiles(fs, plan.targetDir, "~/", normalizeImportAlias(plan.request.importAlias)), + ); + yield* Effect.promise(() => + replaceTextInFiles( + fs, + plan.targetDir, + "@/", + plan.request.importAlias + .replace(IMPORT_ALIAS_WILDCARD_REGEX, "") + .replace(IMPORT_ALIAS_TRAILING_SLASH_REGEX, "/"), + ), + ); + } + + let nextSettings = plan.settings; + if (plan.tasks.bootstrapFileMaker && plan.request.fileMaker) { + const fileMakerInputs = plan.request.fileMaker; + nextSettings = yield* Effect.promise(() => + fileMakerService.bootstrap(plan.targetDir, nextSettings, fileMakerInputs, plan.request.appType), + ); + yield* Effect.promise(() => settingsService.writeSettings(plan.targetDir, nextSettings)); + } + + if (plan.tasks.runInstall) { + let installArgs: string[] = ["install"]; + if (plan.request.packageManager === "yarn") { + installArgs = []; + } + yield* Effect.promise(() => + processService.run(plan.request.packageManager, installArgs, { + cwd: plan.targetDir, + stdout: "pipe", + stderr: "pipe", + }), + ); + } + + if (plan.tasks.runInitialCodegen) { + yield* Effect.promise(() => codegenService.runInitial(plan.targetDir, plan.request.packageManager)); + } + + if (plan.tasks.initializeGit) { + yield* Effect.promise(() => gitService.initialize(plan.targetDir)); + } + + const packageManagerVersion = yield* Effect.promise(() => + packageManagerService.getVersion(plan.request.packageManager, plan.targetDir), + ); + + consoleService.success( + `Created ${plan.request.scopedAppName} in ${plan.targetDir}${ + packageManagerVersion ? ` using ${plan.request.packageManager}@${packageManagerVersion}` : "" + }`, + ); + consoleService.note(plan.nextSteps.map((step) => ` ${step}`).join("\n"), "Next steps"); + return plan; + }); diff --git a/packages/new/src/core/planInit.ts b/packages/new/src/core/planInit.ts new file mode 100644 index 00000000..fb33c7cc --- /dev/null +++ b/packages/new/src/core/planInit.ts @@ -0,0 +1,147 @@ +import path from "node:path"; +import type { PackageJson } from "type-fest"; + +import type { InitPlan, InitRequest, ProofKitSettings } from "~/core/types.js"; +import { formatPackageManagerCommand, getScaffoldVersion, getTemplatePackageCommand } from "~/utils/projectFiles.js"; +import { getNodeMajorVersion, getProofkitReleaseTag } from "~/utils/versioning.js"; + +function createDefaultSettings(request: InitRequest): ProofKitSettings { + return { + ui: request.ui, + appType: request.appType, + envFile: ".env", + dataSources: [], + replacedMainPage: false, + registryTemplates: [], + }; +} + +function createEnvFileContent() { + return ["# When adding additional environment variables, update the schema alongside this file.", ""].join("\n"); +} + +const sharedUiDependencies = { + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + clsx: "^2.1.1", + "lucide-react": "^0.577.0", + "tailwind-merge": "^3.5.0", + tailwindcss: "^4.1.10", + "tw-animate-css": "^1.4.0", +} satisfies Record; + +export function planInit( + request: InitRequest, + options: { templateDir: string; packageManagerVersion?: string }, +): InitPlan { + const targetDir = path.resolve(request.cwd, request.appDir); + const releaseTag = getProofkitReleaseTag(); + const settings = createDefaultSettings(request); + const packageManagerCommand = getTemplatePackageCommand(request.packageManager); + + const packageJson: InitPlan["packageJson"] = { + name: request.scopedAppName, + packageManager: options.packageManagerVersion + ? `${request.packageManager}@${options.packageManagerVersion}` + : undefined, + proofkitMetadata: { + initVersion: getScaffoldVersion(), + scaffoldPackage: "@proofkit/new", + }, + dependencies: {}, + devDependencies: { + "@types/node": `^${getNodeMajorVersion()}`, + }, + }; + + if (request.appType === "browser") { + Object.assign(packageJson.dependencies, sharedUiDependencies); + packageJson.dependencies["@tailwindcss/postcss"] = "^4.1.10"; + packageJson.dependencies["next-themes"] = "^0.4.6"; + } + + if (request.appType === "webviewer") { + Object.assign(packageJson.dependencies, sharedUiDependencies); + packageJson.dependencies["@proofkit/fmdapi"] = releaseTag; + packageJson.dependencies["@proofkit/webviewer"] = releaseTag; + packageJson.dependencies.zod = "^4"; + packageJson.devDependencies["@proofkit/typegen"] = releaseTag; + packageJson.devDependencies["@tailwindcss/vite"] = "^4.2.1"; + } + + return { + request, + targetDir, + templateDir: options.templateDir, + packageManagerCommand, + packageJson, + settings, + envFile: { + path: path.join(targetDir, ".env"), + content: createEnvFileContent(), + }, + writes: [], + commands: [ + ...(request.noInstall ? [] : [{ type: "install" as const }]), + ...(request.dataSource === "filemaker" && + !request.skipFileMakerSetup && + !(request.appType === "webviewer" && request.nonInteractive && !request.hasExplicitFileMakerInputs) + ? [{ type: "codegen" as const }] + : []), + ...(request.noGit ? [] : [{ type: "git-init" as const }]), + ], + tasks: { + bootstrapFileMaker: request.dataSource === "filemaker" && !request.skipFileMakerSetup, + runInstall: !request.noInstall, + runInitialCodegen: + request.dataSource === "filemaker" && + !request.skipFileMakerSetup && + !(request.appType === "webviewer" && request.nonInteractive && !request.hasExplicitFileMakerInputs), + initializeGit: !request.noGit, + }, + nextSteps: [ + `cd ${request.appDir}`, + ...(request.noInstall ? [request.packageManager === "yarn" ? "yarn" : `${request.packageManager} install`] : []), + formatPackageManagerCommand(request.packageManager, "dev"), + ...(request.appType === "webviewer" + ? [ + formatPackageManagerCommand(request.packageManager, "typegen"), + formatPackageManagerCommand(request.packageManager, "launch-fm"), + ] + : []), + formatPackageManagerCommand(request.packageManager, "proofkit"), + ], + }; +} + +export function applyPackageJsonMutations( + packageJson: PackageJson, + mutations: InitPlan["packageJson"], + overwriteDependencies = true, +) { + packageJson.name = mutations.name; + packageJson.proofkitMetadata = mutations.proofkitMetadata as PackageJson["proofkitMetadata"]; + if (mutations.packageManager) { + packageJson.packageManager = mutations.packageManager; + } + + if (!packageJson.dependencies) { + packageJson.dependencies = {}; + } + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + } + + const merge = (target: Record, source: Record) => { + for (const [name, version] of Object.entries(source)) { + if (overwriteDependencies || !(name in target)) { + target[name] = version; + } + } + }; + + merge(packageJson.dependencies as Record, mutations.dependencies); + merge(packageJson.devDependencies as Record, mutations.devDependencies); + + return packageJson; +} diff --git a/packages/new/src/core/resolveInitRequest.ts b/packages/new/src/core/resolveInitRequest.ts new file mode 100644 index 00000000..9d7f17c8 --- /dev/null +++ b/packages/new/src/core/resolveInitRequest.ts @@ -0,0 +1,457 @@ +import { Effect } from "effect"; + +import { DEFAULT_APP_NAME } from "~/consts.js"; +import { CliContext, FileMakerService, PromptService } from "~/core/context.js"; +import type { AppType, CliFlags, DataSourceType, FileMakerInputs, InitRequest } from "~/core/types.js"; +import { createDataSourceEnvNames, getDefaultSchemaName } from "~/utils/projectFiles.js"; +import { parseNameAndPath, validateAppName } from "~/utils/projectName.js"; + +const defaultFlags: CliFlags = { + noGit: false, + noInstall: false, + force: false, + default: false, + CI: false, + importAlias: "~/", +}; + +function compareSemver(left: string, right: string) { + const leftParts = left.split(".").map((part) => Number.parseInt(part, 10) || 0); + const rightParts = right.split(".").map((part) => Number.parseInt(part, 10) || 0); + + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const leftValue = leftParts[index] ?? 0; + const rightValue = rightParts[index] ?? 0; + if (leftValue > rightValue) { + return 1; + } + if (leftValue < rightValue) { + return -1; + } + } + + return 0; +} + +function validateLayoutInputs(flags: CliFlags) { + const hasLayoutName = Boolean(flags.layoutName); + const hasSchemaName = Boolean(flags.schemaName); + + if (hasLayoutName !== hasSchemaName) { + throw new Error("Both --layout-name and --schema-name must be provided together."); + } +} + +async function resolveHostedFileMakerInputs({ + prompt, + fileMakerService, + flags, + nonInteractive, +}: { + prompt: PromptService; + fileMakerService: FileMakerService; + flags: CliFlags; + nonInteractive: boolean; +}): Promise { + validateLayoutInputs(flags); + + if (!flags.server && nonInteractive) { + throw new Error( + "Missing required hosted FileMaker inputs in non-interactive mode: --server, --file-name, and --data-api-key.", + ); + } + + const rawServer = + flags.server ?? + (await prompt.text({ + message: "What is the URL of your FileMaker Server?", + validate: (value) => { + try { + const normalized = value.startsWith("http") ? value : `https://${value}`; + new URL(normalized); + return; + } catch { + return "Please enter a valid URL"; + } + }, + })); + + const { normalizedUrl, versions } = await fileMakerService.validateHostedServerUrl(rawServer); + const hostedUrl = new URL(normalizedUrl); + const demoFileName = "ProofKitDemo.fmp12"; + + let selectedFile = flags.fileName; + let dataApiKey = flags.dataApiKey; + let layoutName = flags.layoutName; + let schemaName = flags.schemaName; + let token: string | undefined; + let files: Awaited> = []; + const requireHostedToken = () => { + if (!token) { + throw new Error("OttoFMS authentication is required for hosted setup."); + } + return token; + }; + + if (!(selectedFile && dataApiKey)) { + if (!(flags.adminApiKey || (versions.ottoVersion && compareSemver(versions.ottoVersion, "4.7.0") >= 0))) { + throw new Error( + "OttoFMS 4.7.0 or later is required to auto-login. Upgrade OttoFMS or pass --admin-api-key for hosted setup.", + ); + } + token = flags.adminApiKey ?? (await fileMakerService.getOttoFMSToken({ url: hostedUrl })).token; + } + + if (!selectedFile) { + if (nonInteractive) { + throw new Error("Missing required FileMaker inputs in non-interactive mode: --file-name, --data-api-key."); + } + + files = await fileMakerService.listFiles({ url: hostedUrl, token: requireHostedToken() }); + selectedFile = await prompt.searchSelect({ + message: "Which file would you like to connect to?", + options: [ + { + value: "$deploy-demo", + label: "Deploy NEW ProofKit Demo File", + hint: "Use OttoFMS to deploy a new file for testing", + keywords: ["demo", "proofkit"], + }, + ...files + .slice() + .sort((left, right) => left.filename.localeCompare(right.filename)) + .map((file) => ({ + value: file.filename, + label: file.filename, + hint: file.status, + keywords: [file.filename], + })), + ], + }); + } + + if (!selectedFile) { + throw new Error("No FileMaker file was selected."); + } + + if (selectedFile === "$deploy-demo") { + if (files.length === 0) { + files = await fileMakerService.listFiles({ url: hostedUrl, token: requireHostedToken() }); + } + const demoExists = files.some((file) => file.filename === demoFileName); + const replaceDemo = + demoExists && !nonInteractive + ? await prompt.confirm({ + message: "The demo file already exists. Do you want to replace it with a fresh copy?", + initialValue: false, + }) + : demoExists; + const deployed = await fileMakerService.deployDemoFile({ + url: hostedUrl, + token: requireHostedToken(), + operation: replaceDemo ? "replace" : "install", + }); + selectedFile = deployed.filename; + dataApiKey = deployed.apiKey; + layoutName ??= "API_Contacts"; + schemaName ??= "Contacts"; + } + + if (!dataApiKey && nonInteractive) { + throw new Error("Missing required FileMaker inputs in non-interactive mode: --data-api-key."); + } + + if (!dataApiKey) { + const apiKeys = (await fileMakerService.listAPIKeys({ url: hostedUrl, token: requireHostedToken() })).filter( + (apiKey) => apiKey.database === selectedFile, + ); + + const selection = + apiKeys.length === 0 + ? "create" + : await prompt.searchSelect({ + message: "Which OttoFMS Data API key would you like to use?", + options: [ + ...apiKeys.map((apiKey) => ({ + value: apiKey.key, + label: `${apiKey.label} - ${apiKey.user}`, + hint: `${apiKey.key.slice(0, 5)}...${apiKey.key.slice(-4)}`, + keywords: [apiKey.label, apiKey.user, apiKey.database], + })), + { + value: "create", + label: "Create a new API key", + hint: "Requires FileMaker credentials for this file", + keywords: ["create", "new"], + }, + ], + }); + + if (selection === "create") { + const username = await prompt.text({ + message: `Enter the account name for ${selectedFile}`, + validate: (value) => (value ? undefined : "An account name is required"), + }); + const password = await prompt.password({ + message: `Enter the password for ${username}`, + validate: (value) => (value ? undefined : "A password is required"), + }); + dataApiKey = ( + await fileMakerService.createDataAPIKeyWithCredentials({ + url: hostedUrl, + filename: selectedFile, + username, + password, + }) + ).apiKey; + } else { + dataApiKey = selection; + } + } + + if (!dataApiKey) { + throw new Error("No FileMaker Data API key was selected."); + } + + const layouts = await fileMakerService.listLayouts({ + dataApiKey, + fmFile: selectedFile, + server: hostedUrl.origin, + }); + + if (layoutName && !layouts.includes(layoutName)) { + throw new Error(`Layout "${layoutName}" was not found in ${selectedFile}.`); + } + + if (!(nonInteractive || layoutName || schemaName)) { + const shouldConfigureLayout = await prompt.confirm({ + message: "Do you want to configure an initial layout for type generation now?", + initialValue: false, + }); + + if (shouldConfigureLayout) { + layoutName = await prompt.searchSelect({ + message: "Select a layout to read data from", + options: layouts.map((layout) => ({ + value: layout, + label: layout, + keywords: [layout], + })), + }); + + schemaName = await prompt.text({ + message: "What should the generated schema be called?", + defaultValue: getDefaultSchemaName(layoutName), + validate: (value) => (value ? undefined : "A schema name is required"), + }); + } + } + + return { + mode: "hosted-otto", + dataSourceName: "filemaker", + envNames: createDataSourceEnvNames("filemaker"), + server: hostedUrl.origin, + fileName: selectedFile, + dataApiKey, + layoutName, + schemaName, + adminApiKey: flags.adminApiKey, + fmsVersion: versions.fmsVersion, + ottoVersion: versions.ottoVersion, + }; +} + +async function resolveFileMakerInputs({ + prompt, + fileMakerService, + flags, + appType, + nonInteractive, +}: { + prompt: PromptService; + fileMakerService: FileMakerService; + flags: CliFlags; + appType: AppType; + nonInteractive: boolean; +}) { + if (flags.dataSource !== "filemaker") { + return { fileMaker: undefined, skipFileMakerSetup: false }; + } + + validateLayoutInputs(flags); + + if (appType === "webviewer" && !flags.server) { + const localFmHttp = await fileMakerService.detectLocalFmHttp(); + if (localFmHttp.healthy && localFmHttp.connectedFiles[0]) { + return { + fileMaker: { + mode: "local-fm-http", + dataSourceName: "filemaker", + envNames: createDataSourceEnvNames("filemaker"), + fmHttpBaseUrl: localFmHttp.baseUrl, + fileName: localFmHttp.connectedFiles[0], + layoutName: flags.layoutName, + schemaName: flags.schemaName, + } satisfies FileMakerInputs, + skipFileMakerSetup: false, + }; + } + + if (nonInteractive) { + throw new Error( + "No local FM HTTP connection was detected and no FileMaker server was provided. Start FM HTTP locally or rerun with --server.", + ); + } + + const fallbackAction = await prompt.select({ + message: "Local FM HTTP was not detected. How would you like to continue?", + options: [ + { + value: "hosted", + label: "Continue with hosted setup", + hint: "Use OttoFMS and a hosted FileMaker server", + }, + { + value: "skip", + label: "Skip for now", + hint: "Create the project and configure FileMaker later", + }, + ], + }); + + if (fallbackAction === "skip") { + return { + fileMaker: undefined, + skipFileMakerSetup: true, + }; + } + } + + return { + fileMaker: await resolveHostedFileMakerInputs({ + prompt, + fileMakerService, + flags, + nonInteractive, + }), + skipFileMakerSetup: false, + }; +} + +export const resolveInitRequest = (name?: string, rawFlags?: CliFlags) => + Effect.gen(function* () { + const flags = { ...defaultFlags, ...rawFlags }; + const prompt = yield* PromptService; + const fileMakerService = yield* FileMakerService; + const cliContext = yield* CliContext; + const nonInteractive = cliContext.nonInteractive || flags.CI || flags.nonInteractive === true; + + let projectName = name; + if (!projectName) { + if (nonInteractive) { + return yield* Effect.fail(new Error("Project name is required in non-interactive mode.")); + } + + projectName = yield* Effect.promise(() => + prompt.text({ + message: "What will your project be called?", + defaultValue: DEFAULT_APP_NAME, + validate: validateAppName, + }), + ); + } + + if (!projectName) { + return yield* Effect.fail(new Error("Project name is required.")); + } + + const validationError = validateAppName(projectName); + if (validationError) { + return yield* Effect.fail(new Error(validationError)); + } + + let appType: AppType = flags.appType ?? "browser"; + if (!(flags.appType || nonInteractive)) { + appType = yield* Effect.promise(() => + prompt.select({ + message: "What kind of app do you want to build?", + options: [ + { + value: "browser", + label: "Web App for Browsers", + hint: "Uses Next.js and hosted deployment", + }, + { + value: "webviewer", + label: "FileMaker Web Viewer", + hint: "Uses Vite for FileMaker web viewers", + }, + ], + }), + ).pipe(Effect.map((value) => value as AppType)); + } + + const hasExplicitFileMakerInputs = Boolean( + flags.server || flags.adminApiKey || flags.dataApiKey || flags.fileName || flags.layoutName || flags.schemaName, + ); + + let dataSource: DataSourceType = "none"; + if (flags.dataSource) { + dataSource = flags.dataSource; + } else if (appType === "webviewer") { + dataSource = hasExplicitFileMakerInputs || !(nonInteractive && !flags.server) ? "filemaker" : "none"; + } + + if (!(nonInteractive || flags.dataSource) && appType !== "webviewer") { + dataSource = yield* Effect.promise(() => + prompt.select({ + message: "Do you want to connect to a FileMaker Database now?", + options: [ + { + value: "filemaker", + label: "Yes", + hint: "Set up env, datasource config, and typegen now", + }, + { + value: "none", + label: "No", + hint: "You can add a data source later", + }, + ], + }), + ).pipe(Effect.map((value) => value as DataSourceType)); + } + + const { fileMaker, skipFileMakerSetup } = yield* Effect.promise(() => + resolveFileMakerInputs({ + prompt, + fileMakerService, + flags: { ...flags, dataSource }, + appType, + nonInteractive, + }), + ); + + const [scopedAppName, appDir] = parseNameAndPath(projectName); + + return { + projectName, + scopedAppName, + appDir, + appType, + ui: flags.ui ?? "shadcn", + dataSource, + packageManager: cliContext.packageManager, + noInstall: flags.noInstall, + noGit: flags.noGit, + force: flags.force, + cwd: cliContext.cwd, + importAlias: flags.importAlias, + nonInteractive, + debug: cliContext.debug, + fileMaker, + skipFileMakerSetup, + hasExplicitFileMakerInputs, + } satisfies InitRequest; + }); diff --git a/packages/new/src/core/types.ts b/packages/new/src/core/types.ts new file mode 100644 index 00000000..a027bc88 --- /dev/null +++ b/packages/new/src/core/types.ts @@ -0,0 +1,137 @@ +import type { PackageManager } from "~/utils/packageManager.js"; + +export type AppType = "browser" | "webviewer"; +export type UIType = "shadcn" | "mantine"; +export type DataSourceType = "filemaker" | "none"; +export type OverwriteMode = "overwrite" | "clear"; +export type FileMakerMode = "hosted-otto" | "local-fm-http"; + +export interface CliFlags { + noGit: boolean; + noInstall: boolean; + force: boolean; + default: boolean; + importAlias: string; + debug?: boolean; + server?: string; + adminApiKey?: string; + fileName?: string; + layoutName?: string; + schemaName?: string; + dataApiKey?: string; + auth?: "none"; + dataSource?: DataSourceType; + ui?: UIType; + CI: boolean; + nonInteractive?: boolean; + appType?: AppType; +} + +export interface FileMakerEnvNames { + database: string; + server: string; + apiKey: string; +} + +export interface HostedFileMakerInputs { + mode: "hosted-otto"; + dataSourceName: string; + envNames: FileMakerEnvNames; + server: string; + fileName: string; + dataApiKey: string; + layoutName?: string; + schemaName?: string; + adminApiKey?: string; + fmsVersion?: string; + ottoVersion?: string | null; +} + +export interface LocalFmHttpInputs { + mode: "local-fm-http"; + dataSourceName: string; + envNames: FileMakerEnvNames; + fmHttpBaseUrl: string; + fileName: string; + layoutName?: string; + schemaName?: string; +} + +export type FileMakerInputs = HostedFileMakerInputs | LocalFmHttpInputs; + +export interface InitRequest { + projectName: string; + scopedAppName: string; + appDir: string; + appType: AppType; + ui: UIType; + dataSource: DataSourceType; + packageManager: PackageManager; + noInstall: boolean; + noGit: boolean; + force: boolean; + cwd: string; + importAlias: string; + nonInteractive: boolean; + debug: boolean; + skipFileMakerSetup: boolean; + fileMaker?: FileMakerInputs; + hasExplicitFileMakerInputs: boolean; +} + +export interface ProofKitSettings { + ui: UIType; + appType: AppType; + envFile?: string; + dataSources: Array<{ + type: "fm"; + name: string; + envNames: { + database: string; + server: string; + apiKey: string; + }; + }>; + replacedMainPage: boolean; + registryTemplates: string[]; +} + +export interface InitPlan { + request: InitRequest; + targetDir: string; + templateDir: string; + overwriteMode?: OverwriteMode; + packageManagerCommand: string; + packageJson: { + name: string; + packageManager?: string; + proofkitMetadata: { + initVersion: string; + scaffoldPackage: "@proofkit/new"; + }; + dependencies: Record; + devDependencies: Record; + }; + settings: ProofKitSettings; + envFile: { + path: string; + content: string; + }; + writes: Array<{ + path: string; + content: string; + }>; + commands: Array<{ type: "install" } | { type: "codegen" } | { type: "git-init" }>; + tasks: { + bootstrapFileMaker: boolean; + runInstall: boolean; + runInitialCodegen: boolean; + initializeGit: boolean; + }; + nextSteps: string[]; +} + +export interface InitResult { + request: InitRequest; + plan: InitPlan; +} diff --git a/packages/new/src/index.ts b/packages/new/src/index.ts new file mode 100644 index 00000000..36a47774 --- /dev/null +++ b/packages/new/src/index.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { optional as optionalArg, text as textArg, withDescription as withArgDescription } from "@effect/cli/Args"; +import { + make as makeCommand, + run, + withDescription as withCommandDescription, + withSubcommands, +} from "@effect/cli/Command"; +import { + boolean as booleanOption, + choice as choiceOption, + optional as optionalOption, + text as textOption, + withAlias, + withDescription as withOptionDescription, +} from "@effect/cli/Options"; +import { layer as nodeContextLayer } from "@effect/platform-node/NodeContext"; +import { runMain } from "@effect/platform-node/NodeRuntime"; +import { Effect } from "effect"; +import { getOrUndefined } from "effect/Option"; +import { cliName } from "~/consts.js"; +import { + CliContext, + ConsoleService, + FileSystemService, + PackageManagerService, + TemplateService, +} from "~/core/context.js"; +import { executeInitPlan } from "~/core/executeInitPlan.js"; +import { planInit } from "~/core/planInit.js"; +import { resolveInitRequest } from "~/core/resolveInitRequest.js"; +import type { CliFlags } from "~/core/types.js"; +import { makeLiveLayer } from "~/services/live.js"; +import { intro } from "~/utils/prompts.js"; +import { proofGradient, renderTitle } from "~/utils/renderTitle.js"; + +const defaultCliFlags: CliFlags = { + noGit: false, + noInstall: false, + force: false, + default: false, + CI: false, + importAlias: "~/", +}; + +function getCliVersion() { + try { + const packageJsonUrl = new URL("../package.json", import.meta.url); + const packageJson = JSON.parse(readFileSync(fileURLToPath(packageJsonUrl), "utf8")) as { version?: string }; + return packageJson.version ?? "0.0.0-private"; + } catch { + return "0.0.0-private"; + } +} + +export const runInit = (name?: string, rawFlags?: Partial) => + Effect.gen(function* () { + const templateService = yield* TemplateService; + const packageManagerService = yield* PackageManagerService; + const request = yield* resolveInitRequest(name, { ...defaultCliFlags, ...rawFlags }); + const templateDir = templateService.getTemplateDir(request.appType, request.ui); + const packageManagerVersion = yield* Effect.promise(() => + packageManagerService.getVersion(request.packageManager, request.cwd), + ); + const plan = planInit(request, { templateDir, packageManagerVersion }); + yield* executeInitPlan(plan); + return { request, plan }; + }); + +export const runDefaultCommand = (rawFlags?: Partial) => + Effect.gen(function* () { + const cliContext = yield* CliContext; + const fs = yield* FileSystemService; + const consoleService = yield* ConsoleService; + const flags = { ...defaultCliFlags, ...rawFlags }; + + if (cliContext.nonInteractive || flags.CI || flags.nonInteractive) { + return yield* Effect.fail( + new Error( + "The default command is interactive-only in non-interactive mode. Run an explicit command such as `proofkit-new init --non-interactive`.", + ), + ); + } + + const settingsPath = path.join(cliContext.cwd, "proofkit.json"); + const hasProofKitProject = yield* Effect.promise(() => fs.exists(settingsPath)); + + if (hasProofKitProject) { + intro(`Found ${proofGradient("ProofKit")} project`); + consoleService.note( + [ + "Project command routing is coming soon in this new CLI.", + "For now, the available explicit command is `proofkit-new init `.", + ].join("\n"), + "Coming soon", + ); + return; + } + + intro(`No ${proofGradient("ProofKit")} project found, running \`init\``); + yield* runInit(undefined, { + ...flags, + default: true, + }); + }); + +const initDirectoryArg = optionalArg(textArg({ name: "dir" })).pipe( + withArgDescription("The project name or target directory"), +); + +function optionalTextOption(name: string, description: string) { + return optionalOption(textOption(name).pipe(withOptionDescription(description))); +} + +function optionalChoiceOption(name: string, choices: Choices, description: string) { + return optionalOption(choiceOption(name, choices).pipe(withOptionDescription(description))); +} + +function makeInitCommand() { + return makeCommand( + "init", + { + dir: initDirectoryArg, + appType: optionalChoiceOption("app-type", ["browser", "webviewer"] as const, "The type of app to create"), + ui: optionalChoiceOption("ui", ["shadcn", "mantine"] as const, "The UI scaffold to create"), + server: optionalTextOption("server", "The URL of your FileMaker Server"), + adminApiKey: optionalTextOption("admin-api-key", "Admin API key for OttoFMS"), + fileName: optionalTextOption("file-name", "The name of the FileMaker file"), + layoutName: optionalTextOption("layout-name", "The FileMaker layout name to scaffold"), + schemaName: optionalTextOption("schema-name", "The generated schema name"), + dataApiKey: optionalTextOption("data-api-key", "The Otto Data API key to use"), + dataSource: optionalChoiceOption("data-source", ["filemaker", "none"] as const, "The data source to use"), + noGit: booleanOption("no-git").pipe(withOptionDescription("Skip git initialization")), + noInstall: booleanOption("no-install").pipe(withOptionDescription("Skip package installation")), + force: booleanOption("force").pipe( + withAlias("f"), + withOptionDescription("Force overwrite target directory when it already contains files"), + ), + CI: booleanOption("ci").pipe(withOptionDescription("Deprecated alias for --non-interactive")), + nonInteractive: booleanOption("non-interactive").pipe( + withOptionDescription("Never prompt for input; fail when required values are missing"), + ), + debug: booleanOption("debug").pipe(withOptionDescription("Run in debug mode")), + }, + ({ dir, ...options }) => { + const flags: CliFlags = { + ...defaultCliFlags, + appType: getOrUndefined(options.appType), + ui: getOrUndefined(options.ui), + server: getOrUndefined(options.server), + adminApiKey: getOrUndefined(options.adminApiKey), + fileName: getOrUndefined(options.fileName), + layoutName: getOrUndefined(options.layoutName), + schemaName: getOrUndefined(options.schemaName), + dataApiKey: getOrUndefined(options.dataApiKey), + dataSource: getOrUndefined(options.dataSource), + noGit: options.noGit, + noInstall: options.noInstall, + force: options.force, + CI: options.CI, + nonInteractive: options.nonInteractive, + debug: options.debug, + }; + + return makeLiveLayer({ + cwd: process.cwd(), + debug: flags.debug === true, + nonInteractive: Boolean(flags.CI || flags.nonInteractive), + })(runInit(getOrUndefined(dir), flags)); + }, + ).pipe(withCommandDescription("Create a new project with the next ProofKit scaffold")); +} + +const rootCommand = makeCommand( + cliName, + { + CI: booleanOption("ci").pipe(withOptionDescription("Deprecated alias for --non-interactive")), + nonInteractive: booleanOption("non-interactive").pipe( + withOptionDescription("Never prompt for input; fail when required values are missing"), + ), + debug: booleanOption("debug").pipe(withOptionDescription("Run in debug mode")), + }, + (options) => + makeLiveLayer({ + cwd: process.cwd(), + debug: options.debug === true, + nonInteractive: Boolean(options.CI || options.nonInteractive), + })( + runDefaultCommand({ + ...defaultCliFlags, + CI: options.CI, + nonInteractive: options.nonInteractive, + debug: options.debug, + }), + ), +).pipe( + withCommandDescription("Internal scaffold package for the next ProofKit CLI"), + withSubcommands([makeInitCommand()]), +); + +export const cli = run(rootCommand, { + name: "ProofKit New", + version: getCliVersion(), +}); + +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMainModule) { + renderTitle(getCliVersion()); + runMain(cli(process.argv).pipe(Effect.provide(nodeContextLayer))); +} diff --git a/packages/new/src/services/live.ts b/packages/new/src/services/live.ts new file mode 100644 index 00000000..25cec045 --- /dev/null +++ b/packages/new/src/services/live.ts @@ -0,0 +1,615 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { Effect, Layer } from "effect"; +import { execa } from "execa"; +import fs from "fs-extra"; +import { TEMPLATE_ROOT } from "~/consts.js"; +import { + CliContext, + type CliContextValue, + CodegenService, + ConsoleService, + type FileMakerBootstrapArtifacts, + FileMakerService, + FileSystemService, + GitService, + type OttoApiKeyInfo, + type OttoFileInfo, + PackageManagerService, + ProcessService, + PromptService, + SettingsService, + TemplateService, +} from "~/core/context.js"; +import { UserAbortedError } from "~/core/errors.js"; +import type { AppType, FileMakerInputs, ProofKitSettings, UIType } from "~/core/types.js"; +import { openBrowser } from "~/utils/browserOpen.js"; +import { deleteJson, getJson, postJson } from "~/utils/http.js"; +import { detectUserPackageManager } from "~/utils/packageManager.js"; +import { createDataSourceEnvNames, updateEnvSchemaFile, updateTypegenConfig } from "~/utils/projectFiles.js"; +import { + confirmPrompt, + spinner as createSpinner, + isCancel, + log, + multiSearchSelectPrompt, + note, + passwordPrompt, + searchSelectPrompt, + selectPrompt, + textPrompt, +} from "~/utils/prompts.js"; + +function unwrap(value: T | symbol): T { + if (isCancel(value)) { + throw new UserAbortedError(); + } + return value as T; +} + +function normalizeUrl(serverUrl: string) { + if (serverUrl.startsWith("https://")) { + return serverUrl; + } + if (serverUrl.startsWith("http://")) { + return serverUrl.replace("http://", "https://"); + } + return `https://${serverUrl}`; +} + +interface LayoutFolder { + isFolder?: boolean; + name?: string; + folderLayoutNames?: LayoutFolder[]; +} + +function transformLayoutList(layouts: LayoutFolder[]): string[] { + const flatten = (layout: LayoutFolder): string[] => { + if (layout.isFolder === true) { + const folderLayouts = Array.isArray(layout.folderLayoutNames) ? layout.folderLayoutNames : []; + return folderLayouts.flatMap((item) => flatten(item)); + } + return typeof layout.name === "string" ? [layout.name] : []; + }; + + return layouts.flatMap(flatten).sort((left, right) => left.localeCompare(right)); +} + +const promptService = { + text: async (options: { message: string; defaultValue?: string; validate?: (value: string) => string | undefined }) => + unwrap( + await textPrompt({ + message: options.message, + defaultValue: options.defaultValue, + validate: options.validate, + }), + ).toString(), + password: async (options: { message: string; validate?: (value: string) => string | undefined }) => + unwrap( + await passwordPrompt({ + message: options.message, + validate: options.validate, + }), + ).toString(), + select: async (options: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + }) => + unwrap( + await selectPrompt({ + message: options.message, + options: options.options, + }), + ) as T, + searchSelect: async (options: { + message: string; + emptyMessage?: string; + options: Array<{ value: T; label: string; hint?: string; keywords?: string[]; disabled?: boolean | string }>; + }) => unwrap(await searchSelectPrompt(options)) as T, + multiSearchSelect: async (options: { + message: string; + options: Array<{ value: T; label: string; hint?: string; keywords?: string[]; disabled?: boolean | string }>; + required?: boolean; + }) => unwrap(await multiSearchSelectPrompt(options)), + confirm: async (options: { message: string; initialValue?: boolean }) => + unwrap( + await confirmPrompt({ + message: options.message, + initialValue: options.initialValue, + }), + ) as boolean, +}; + +const consoleService = { + info: (message: string) => log.info(message), + warn: (message: string) => log.warn(message), + error: (message: string) => log.error(message), + success: (message: string) => log.success(message), + note: (message: string, title?: string) => note(message, title), +}; + +const fileSystemService = { + exists: async (targetPath: string) => fs.pathExists(targetPath), + readdir: async (targetPath: string) => fs.readdir(targetPath), + emptyDir: async (targetPath: string) => fs.emptyDir(targetPath), + copyDir: async (from: string, to: string, options?: { overwrite?: boolean }) => + fs.copy(from, to, { overwrite: options?.overwrite ?? true }), + rename: async (from: string, to: string) => fs.rename(from, to), + remove: async (targetPath: string) => fs.remove(targetPath), + readJson: async (targetPath: string) => fs.readJson(targetPath) as Promise, + writeJson: async (targetPath: string, value: unknown) => fs.writeJson(targetPath, value, { spaces: 2 }), + writeFile: async (targetPath: string, content: string) => fs.writeFile(targetPath, content, "utf8"), + readFile: async (targetPath: string) => fs.readFile(targetPath, "utf8"), +}; + +const templateService = { + getTemplateDir: (appType: AppType, ui: UIType) => { + if (appType === "webviewer") { + return path.join(TEMPLATE_ROOT, "vite-wv"); + } + if (ui === "mantine") { + return path.join(TEMPLATE_ROOT, "nextjs-mantine"); + } + return path.join(TEMPLATE_ROOT, "nextjs-shadcn"); + }, +}; + +const packageManagerService = { + getVersion: async (packageManager: string, cwd: string) => { + if (packageManager === "bun") { + return undefined; + } + const { stdout } = await execa(packageManager, ["-v"], { cwd }); + return stdout.trim(); + }, +}; + +const processService = { + run: async ( + command: string, + args: string[], + options: { + cwd: string; + stdout?: "pipe" | "inherit" | "ignore"; + stderr?: "pipe" | "inherit" | "ignore"; + }, + ) => { + const result = await execa(command, args, { + cwd: options.cwd, + stdout: options.stdout ?? "pipe", + stderr: options.stderr ?? "pipe", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + }, +}; + +const gitService = { + initialize: async (projectDir: string) => { + await execa("git", ["init"], { cwd: projectDir }); + await execa("git", ["add", "."], { cwd: projectDir }); + await execa("git", ["commit", "-m", "Initial commit"], { cwd: projectDir }); + }, +}; + +const settingsService = { + writeSettings: async (projectDir: string, settings: ProofKitSettings) => + fs.writeJson(path.join(projectDir, "proofkit.json"), settings, { spaces: 2 }), + appendEnvVars: async (projectDir: string, vars: Record) => { + const envPath = path.join(projectDir, ".env"); + const existing = (await fs.pathExists(envPath)) ? await fs.readFile(envPath, "utf8") : ""; + const additions = Object.entries(vars) + .map(([name, value]) => `${name}=${value}`) + .join("\n"); + const nextContent = [existing.trimEnd(), additions].filter(Boolean).join("\n").concat("\n"); + await fs.writeFile(envPath, nextContent, "utf8"); + }, + ensureTypegenConfig: async (_projectDir: string, _options: { appType: AppType; fileMaker?: FileMakerInputs }) => + undefined, +}; + +function createDataSourceEntry(dataSourceName: string) { + return { + type: "fm" as const, + name: dataSourceName, + envNames: createDataSourceEnvNames(dataSourceName), + }; +} + +function createFileMakerBootstrapArtifacts( + settings: ProofKitSettings, + inputs: FileMakerInputs, + appType: AppType, +): Promise { + const dataSourceEntry = createDataSourceEntry(inputs.dataSourceName); + const nextSettings: ProofKitSettings = { + ...settings, + dataSources: settings.dataSources.some((entry) => entry.name === dataSourceEntry.name) + ? settings.dataSources + : [...settings.dataSources, dataSourceEntry], + }; + + if (inputs.mode === "local-fm-http") { + return Promise.resolve({ + settings: nextSettings, + envVars: {}, + envSchemaEntries: [], + typegenConfig: { + mode: inputs.mode, + dataSourceName: inputs.dataSourceName, + fmHttpBaseUrl: inputs.fmHttpBaseUrl, + connectedFileName: inputs.fileName, + layoutName: inputs.layoutName, + schemaName: inputs.schemaName, + appType, + }, + }); + } + + return Promise.resolve({ + settings: nextSettings, + envVars: { + [inputs.envNames.database]: inputs.fileName, + [inputs.envNames.server]: inputs.server, + [inputs.envNames.apiKey]: inputs.dataApiKey, + }, + envSchemaEntries: [ + { + name: inputs.envNames.database, + zodSchema: 'z.string().endsWith(".fmp12")', + defaultValue: inputs.fileName, + }, + { + name: inputs.envNames.server, + zodSchema: "z.string().url()", + defaultValue: inputs.server, + }, + { + name: inputs.envNames.apiKey, + zodSchema: 'z.string().startsWith("dk_")', + defaultValue: inputs.dataApiKey, + }, + ], + typegenConfig: { + mode: inputs.mode, + dataSourceName: inputs.dataSourceName, + envNames: inputs.envNames, + layoutName: inputs.layoutName, + schemaName: inputs.schemaName, + appType, + }, + }); +} + +const fileMakerService = { + detectLocalFmHttp: async (baseUrl = process.env.FM_HTTP_BASE_URL ?? "http://127.0.0.1:1365") => { + try { + const health = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(3000) }); + if (!health.ok) { + return { baseUrl, healthy: false, connectedFiles: [] }; + } + const connectedFiles = await fetch(`${baseUrl}/connectedFiles`, { signal: AbortSignal.timeout(3000) }) + .then(async (response) => (response.ok ? ((await response.json()) as unknown) : [])) + .catch(() => []); + return { + baseUrl, + healthy: true, + connectedFiles: Array.isArray(connectedFiles) + ? connectedFiles.filter((item): item is string => typeof item === "string") + : [], + }; + } catch { + return { baseUrl, healthy: false, connectedFiles: [] }; + } + }, + validateHostedServerUrl: async (serverUrl: string, ottoPort?: number | null) => { + const normalizedUrl = normalizeUrl(serverUrl); + const fmsUrl = new URL("/fmws/serverinfo", normalizedUrl).toString(); + const fmsResponse = await getJson<{ data?: { ServerVersion?: string } }>(fmsUrl); + const serverVersion = fmsResponse.data?.data?.ServerVersion?.split(" ")[0]; + if (!serverVersion) { + throw new Error(`Invalid FileMaker Server URL: ${normalizedUrl}`); + } + + let ottoVersion: string | null = null; + const otto4Response = await getJson<{ response?: { Otto?: { version?: string } } }>( + new URL("/otto/api/info", normalizedUrl).toString(), + ).catch(() => undefined); + ottoVersion = otto4Response?.data?.response?.Otto?.version ?? null; + + if (!ottoVersion) { + const otto3Url = new URL(normalizedUrl); + otto3Url.port = ottoPort ? String(ottoPort) : "3030"; + otto3Url.pathname = "/api/otto/info"; + const otto3Response = await getJson<{ Otto?: { version?: string } }>(otto3Url.toString()).catch(() => undefined); + ottoVersion = otto3Response?.data?.Otto?.version ?? null; + } + + return { + normalizedUrl: new URL(normalizedUrl).origin, + versions: { + fmsVersion: serverVersion, + ottoVersion, + }, + }; + }, + getOttoFMSToken: async ({ url }: { url: URL }) => { + const hash = randomUUID().replaceAll("-", "").slice(0, 18); + const loginUrl = new URL(`/otto/wizard/${hash}`, url.origin); + log.info(`If the browser window didn't open automatically, use this Otto login URL:\n${loginUrl.toString()}`); + await openBrowser(loginUrl.toString()); + + const spin = createSpinner(); + spin.start("Waiting for OttoFMS login"); + + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const response = await getJson<{ response?: { token?: string } }>( + `${url.origin}/otto/api/cli/checkHash/${hash}`, + { headers: { "Accept-Encoding": "deflate" }, timeout: 5000 }, + ).catch(() => undefined); + const token = response?.data?.response?.token; + if (token) { + spin.stop("Login complete"); + await deleteJson(`${url.origin}/otto/api/cli/checkHash/${hash}`, { + headers: { "Accept-Encoding": "deflate" }, + }).catch(() => undefined); + return { token }; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + spin.stop("Login timed out"); + throw new Error("OttoFMS login timed out after 3 minutes."); + }, + listFiles: async ({ url, token }: { url: URL; token: string }) => { + const response = await getJson<{ response?: { databases?: Array<{ filename?: string; status?: string }> } }>( + `${url.origin}/otto/fmi/admin/api/v2/databases`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + const databases = (response.data?.response?.databases ?? []) as Record[]; + return databases + .filter((database): database is { filename: string; status?: string } => typeof database.filename === "string") + .map( + (database) => + ({ + filename: database.filename, + status: database.status ?? "unknown", + }) satisfies OttoFileInfo, + ); + }, + listAPIKeys: async ({ url, token }: { url: URL; token: string }) => { + const response = await getJson<{ response?: { "api-keys"?: Record[] } }>( + `${url.origin}/otto/api/api-key`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + const apiKeys = (response.data?.response?.["api-keys"] ?? []) as Record[]; + return apiKeys + .filter( + (apiKey): apiKey is { key: string; user: string; database: string; label: string } => + typeof apiKey.key === "string" && + typeof apiKey.user === "string" && + typeof apiKey.database === "string" && + typeof apiKey.label === "string", + ) + .map( + (apiKey) => + ({ + key: apiKey.key, + user: apiKey.user, + database: apiKey.database, + label: apiKey.label, + }) satisfies OttoApiKeyInfo, + ); + }, + createDataAPIKeyWithCredentials: async ({ + url, + filename, + username, + password: userPassword, + }: { + url: URL; + filename: string; + username: string; + password: string; + }) => { + const response = await postJson<{ response?: { key?: string } }>(`${url.origin}/otto/api/api-key/create-only`, { + database: filename, + label: "For FM Web App", + user: username, + pass: userPassword, + }); + const apiKey = response.data?.response?.key; + if (!apiKey) { + throw new Error(`Failed to create a Data API key for ${filename}.`); + } + return { apiKey }; + }, + startDeployment: async ({ payload, url, token }: { payload: unknown; url: URL; token: string }) => + postJson<{ response?: { subDeploymentIds?: number[] } }>(`${url.origin}/otto/api/deployment`, payload, { + headers: { + Authorization: `Bearer ${token}`, + }, + }), + getDeploymentStatus: async ({ url, token, deploymentId }: { url: URL; token: string; deploymentId: number }) => + getJson<{ response?: { status?: string; running?: boolean } }>( + `${url.origin}/otto/api/deployment/${deploymentId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + deployDemoFile: async ({ url, token, operation }: { url: URL; token: string; operation: "install" | "replace" }) => { + const demoFileName = "ProofKitDemo.fmp12"; + const spin = createSpinner(); + spin.start("Deploying ProofKit Demo file"); + + const deploymentPayload = { + 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: demoFileName, + }, + operation, + source: { + fileName: demoFileName, + }, + location: { + folder: "default", + subFolder: "", + }, + }, + ], + concurrency: 1, + options: { + closeFilesAfterBuild: false, + keepFilesClosedAfterComplete: false, + transferContainerData: false, + }, + }, + ], + abortRemaining: false, + }; + + const deployment = await fileMakerService.startDeployment({ + payload: deploymentPayload, + url, + token, + }); + + const deploymentId = deployment.data?.response?.subDeploymentIds?.[0]; + if (!deploymentId) { + spin.stop("Demo deployment failed"); + throw new Error("No deployment ID was returned when deploying the demo file."); + } + + const deploymentDeadline = Date.now() + 300_000; + let deploymentCompleted = false; + while (Date.now() < deploymentDeadline) { + await new Promise((resolve) => setTimeout(resolve, 2500)); + const status = await fileMakerService.getDeploymentStatus({ + url, + token, + deploymentId, + }); + + if (!status.data?.response?.running) { + if (status.data?.response?.status !== "complete") { + spin.stop("Demo deployment failed"); + throw new Error("ProofKit Demo deployment did not complete successfully."); + } + deploymentCompleted = true; + break; + } + } + + if (!deploymentCompleted) { + spin.stop("Demo deployment timed out"); + throw new Error("ProofKit Demo deployment timed out after 5 minutes."); + } + + const apiKey = await fileMakerService.createDataAPIKeyWithCredentials({ + url, + filename: demoFileName, + username: "admin", + password: "admin", + }); + spin.stop("Demo file deployed"); + return { apiKey: apiKey.apiKey, filename: demoFileName }; + }, + listLayouts: async ({ dataApiKey, fmFile, server }: { dataApiKey: string; fmFile: string; server: string }) => { + const response = await getJson<{ response?: { layouts?: LayoutFolder[] } }>( + `${server}/otto/fmi/data/vLatest/databases/${encodeURIComponent(fmFile)}/layouts`, + { + headers: { + Authorization: `Bearer ${dataApiKey}`, + }, + }, + ); + return transformLayoutList(response.data?.response?.layouts ?? []); + }, + createFileMakerBootstrapArtifacts, + bootstrap: async (projectDir: string, settings: ProofKitSettings, inputs: FileMakerInputs, appType: AppType) => { + const artifacts = await createFileMakerBootstrapArtifacts(settings, inputs, appType); + if (Object.keys(artifacts.envVars).length > 0) { + await settingsService.appendEnvVars(projectDir, artifacts.envVars); + await updateEnvSchemaFile(fileSystemService, projectDir, artifacts.envSchemaEntries); + } + + await updateTypegenConfig(fileSystemService, projectDir, { + appType: artifacts.typegenConfig.appType, + dataSourceName: artifacts.typegenConfig.dataSourceName, + envNames: artifacts.typegenConfig.envNames, + fmHttpBaseUrl: artifacts.typegenConfig.fmHttpBaseUrl, + connectedFileName: artifacts.typegenConfig.connectedFileName, + layoutName: artifacts.typegenConfig.layoutName, + schemaName: artifacts.typegenConfig.schemaName, + }); + + return artifacts.settings; + }, +}; + +const codegenService = { + runInitial: async (projectDir: string, packageManager: CliContextValue["packageManager"]) => { + let commandParts: string[]; + if (packageManager === "npm") { + commandParts = ["npm", "run", "typegen"]; + } else if (packageManager === "bun") { + commandParts = ["bun", "run", "typegen"]; + } else { + commandParts = [packageManager, "typegen"]; + } + const command = commandParts[0]; + if (!command) { + throw new Error("Unable to resolve the codegen command"); + } + const args = commandParts.slice(1); + await execa(command, args, { cwd: projectDir }); + }, +}; + +export function makeLiveLayer(options: { cwd: string; debug: boolean; nonInteractive: boolean }) { + const cliContext: CliContextValue = { + cwd: options.cwd, + debug: options.debug, + nonInteractive: options.nonInteractive, + packageManager: detectUserPackageManager(), + }; + + const layer = Layer.mergeAll( + Layer.succeed(CliContext, cliContext), + Layer.succeed(PromptService, promptService), + Layer.succeed(ConsoleService, consoleService), + Layer.succeed(FileSystemService, fileSystemService), + Layer.succeed(TemplateService, templateService), + Layer.succeed(PackageManagerService, packageManagerService), + Layer.succeed(ProcessService, processService), + Layer.succeed(GitService, gitService), + Layer.succeed(SettingsService, settingsService), + Layer.succeed(FileMakerService, fileMakerService), + Layer.succeed(CodegenService, codegenService), + ); + + return (effect: Effect.Effect) => Effect.provide(effect, layer); +} diff --git a/packages/new/src/utils/browserOpen.ts b/packages/new/src/utils/browserOpen.ts new file mode 100644 index 00000000..5580d98b --- /dev/null +++ b/packages/new/src/utils/browserOpen.ts @@ -0,0 +1,11 @@ +import open from "open"; + +export async function openBrowser(url: string): Promise { + try { + await open(url); + } catch { + // Ignore open failures and let the user copy the URL manually. + } +} + +export const openExternal: (url: string) => Promise = openBrowser; diff --git a/packages/new/src/utils/http.ts b/packages/new/src/utils/http.ts new file mode 100644 index 00000000..18dfa949 --- /dev/null +++ b/packages/new/src/utils/http.ts @@ -0,0 +1,85 @@ +import https from "node:https"; +import axios from "axios"; + +function createHttpsAgent() { + return new https.Agent({ + rejectUnauthorized: process.env.PROOFKIT_ALLOW_INSECURE_TLS !== "1", + }); +} + +export async function getJson(url: string, options?: { headers?: Record; timeout?: number }) { + const response = await axios.get(url, { + headers: options?.headers, + httpsAgent: createHttpsAgent(), + timeout: options?.timeout ?? 10_000, + validateStatus: null, + }); + return response; +} + +export async function postJson( + url: string, + data: unknown, + options?: { headers?: Record; timeout?: number }, +) { + const response = await axios.post(url, data, { + headers: options?.headers, + httpsAgent: createHttpsAgent(), + timeout: options?.timeout ?? 10_000, + validateStatus: null, + }); + return response; +} + +export async function deleteJson(url: string, options?: { headers?: Record; timeout?: number }) { + const response = await axios.delete(url, { + headers: options?.headers, + httpsAgent: createHttpsAgent(), + timeout: options?.timeout ?? 10_000, + validateStatus: null, + }); + return response; +} + +export async function requestJson( + url: string | URL, + options?: { + method?: "GET" | "POST" | "DELETE"; + headers?: Record; + body?: Record; + timeoutMs?: number; + }, +) { + const response = await axios.request({ + url: url.toString(), + method: options?.method ?? "GET", + data: options?.body, + headers: options?.headers, + httpsAgent: createHttpsAgent(), + timeout: options?.timeoutMs ?? 10_000, + }); + return response; +} + +export async function requestText( + url: string | URL, + options?: { + method?: "GET" | "POST" | "DELETE"; + headers?: Record; + timeoutMs?: number; + }, +) { + const response = await axios.request({ + url: url.toString(), + method: options?.method ?? "GET", + headers: options?.headers, + httpsAgent: createHttpsAgent(), + timeout: options?.timeoutMs ?? 10_000, + responseType: "text", + validateStatus: null, + }); + return { + status: response.status, + data: response.data, + }; +} diff --git a/packages/new/src/utils/packageManager.ts b/packages/new/src/utils/packageManager.ts new file mode 100644 index 00000000..15402c66 --- /dev/null +++ b/packages/new/src/utils/packageManager.ts @@ -0,0 +1,20 @@ +export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +export function detectUserPackageManager(): PackageManager { + const userAgent = process.env.npm_config_user_agent; + + if (userAgent) { + if (userAgent.startsWith("yarn")) { + return "yarn"; + } + if (userAgent.startsWith("pnpm")) { + return "pnpm"; + } + if (userAgent.startsWith("bun")) { + return "bun"; + } + return "npm"; + } + + return "npm"; +} diff --git a/packages/new/src/utils/projectFiles.ts b/packages/new/src/utils/projectFiles.ts new file mode 100644 index 00000000..fc018bf1 --- /dev/null +++ b/packages/new/src/utils/projectFiles.ts @@ -0,0 +1,263 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser"; +import type { FileMakerEnvNames } from "~/core/types.js"; +import type { PackageManager } from "~/utils/packageManager.js"; + +const commonFileMakerLayoutPrefixes = ["API_", "API ", "dapi_", "dapi"]; +const TRAILING_SLASH_REGEX = /[^/]$/; +const textFileExtensions = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".json", + ".jsonc", + ".md", + ".css", + ".scss", + ".html", + ".mjs", + ".cjs", +]); + +export function getDefaultSchemaName(layoutName: string) { + let schemaName = layoutName.replace(/[-\s]/g, "_"); + for (const prefix of commonFileMakerLayoutPrefixes) { + if (schemaName.startsWith(prefix)) { + schemaName = schemaName.replace(prefix, ""); + } + } + return schemaName; +} + +export function createDataSourceEnvNames(dataSourceName: string): FileMakerEnvNames { + if (dataSourceName === "filemaker") { + return { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }; + } + + const upperName = dataSourceName.toUpperCase(); + return { + database: `${upperName}_FM_DATABASE`, + server: `${upperName}_FM_SERVER`, + apiKey: `${upperName}_OTTO_API_KEY`, + }; +} + +export function formatPackageManagerCommand(packageManager: PackageManager, command: string) { + return ["npm", "bun"].includes(packageManager) ? `${packageManager} run ${command}` : `${packageManager} ${command}`; +} + +export function getTemplatePackageCommand(packageManager: PackageManager) { + if (packageManager === "npm") { + return "npm run"; + } + return packageManager; +} + +export function normalizeImportAlias(importAlias: string) { + return importAlias.replace(/\*/g, "").replace(TRAILING_SLASH_REGEX, "$&/"); +} + +export async function replaceTextInFiles( + fs: { + readdir: (path: string) => Promise; + readFile: (path: string) => Promise; + writeFile: (path: string, content: string) => Promise; + }, + rootDir: string, + searchValue: string, + replaceValue: string, +) { + const entries = await fs.readdir(rootDir); + for (const entry of entries) { + const fullPath = path.join(rootDir, entry); + const extension = path.extname(entry); + + if (!(extension || entry.includes("."))) { + await replaceTextInFiles(fs, fullPath, searchValue, replaceValue).catch(() => undefined); + continue; + } + + if (!textFileExtensions.has(extension)) { + continue; + } + + const content = await fs.readFile(fullPath).catch(() => undefined); + if (!content?.includes(searchValue)) { + continue; + } + + await fs.writeFile(fullPath, content.replaceAll(searchValue, replaceValue)); + } +} + +export async function updateEnvSchemaFile( + fs: { + exists: (path: string) => Promise; + readFile: (path: string) => Promise; + writeFile: (path: string, content: string) => Promise; + }, + projectDir: string, + envEntries: Array<{ name: string; zodSchema: string }>, +) { + const envFilePath = path.join(projectDir, "src/lib/env.ts"); + if (!(await fs.exists(envFilePath))) { + return; + } + + let content = await fs.readFile(envFilePath); + const marker = " server: {"; + const markerIndex = content.indexOf(marker); + if (markerIndex === -1) { + return; + } + + const insertIndex = content.indexOf(" },", markerIndex); + if (insertIndex === -1) { + return; + } + + const additions = envEntries + .filter((entry) => !content.includes(`${entry.name}:`)) + .map((entry) => ` ${entry.name}: ${entry.zodSchema},`) + .join("\n"); + + if (!additions) { + return; + } + + content = `${content.slice(0, insertIndex)}${additions}\n${content.slice(insertIndex)}`; + await fs.writeFile(envFilePath, content); +} + +interface TypegenFileContent { + $schema?: string; + config: Record[] | Record; +} + +export async function updateTypegenConfig( + fs: { + exists: (path: string) => Promise; + readFile: (path: string) => Promise; + writeFile: (path: string, content: string) => Promise; + }, + projectDir: string, + options: { + appType: "browser" | "webviewer"; + dataSourceName: string; + envNames?: FileMakerEnvNames; + fmHttpBaseUrl?: string; + connectedFileName?: string; + layoutName?: string; + schemaName?: string; + }, +) { + const configPath = path.join(projectDir, "proofkit-typegen.config.jsonc"); + const dsPath = `./src/config/schemas/${options.dataSourceName}`; + const nextDataSource: Record = { + type: "fmdapi", + layouts: [], + path: dsPath, + clearOldFiles: true, + clientSuffix: "Layout", + }; + + if (options.envNames) { + nextDataSource.envNames = { + server: options.envNames.server, + db: options.envNames.database, + auth: { apiKey: options.envNames.apiKey }, + }; + } + + if (options.appType === "webviewer") { + nextDataSource.webviewerScriptName = "ExecuteDataApi"; + } + + if (options.fmHttpBaseUrl) { + nextDataSource.fmHttp = { + enabled: true, + baseUrl: options.fmHttpBaseUrl, + ...(options.connectedFileName ? { connectedFileName: options.connectedFileName } : {}), + }; + } + + const layout = + options.layoutName && options.schemaName + ? { + layoutName: options.layoutName, + schemaName: options.schemaName, + valueLists: "allowEmpty", + } + : undefined; + + if (layout) { + nextDataSource.layouts = [layout]; + } + + if (!(await fs.exists(configPath))) { + const nextContent: TypegenFileContent = { + $schema: "https://proofkit.dev/typegen-config-schema.json", + config: [nextDataSource], + }; + await fs.writeFile(configPath, `${JSON.stringify(nextContent, null, 2)}\n`); + return; + } + + const original = await fs.readFile(configPath); + const parsed = parseJsonc(original) as TypegenFileContent; + const configArray = Array.isArray(parsed.config) ? parsed.config : [parsed.config]; + const existingIndex = configArray.findIndex((entry) => entry.path === dsPath); + + if (existingIndex === -1) { + configArray.push(nextDataSource); + } else { + const existing = (configArray[existingIndex] ?? {}) as Record; + const existingLayouts = Array.isArray(existing.layouts) ? existing.layouts : []; + let nextLayouts = existingLayouts; + if (layout && !existingLayouts.some((item) => item?.layoutName === layout.layoutName)) { + nextLayouts = [...existingLayouts, layout]; + } + configArray[existingIndex] = { + ...existing, + ...nextDataSource, + layouts: nextLayouts, + }; + } + + const nextConfig = Array.isArray(parsed.config) ? configArray : (configArray[0] ?? nextDataSource); + const edits = modify(original, ["config"], nextConfig, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: "\n", + }, + }); + await fs.writeFile(configPath, applyEdits(original, edits)); +} + +export function getScaffoldVersion() { + try { + const packageJsonUrl = new URL("../../package.json", import.meta.url); + const packageJson = JSON.parse(readFileSync(fileURLToPath(packageJsonUrl), "utf8")) as { version?: string }; + if (packageJson.version && packageJson.version !== "0.0.0-private") { + return packageJson.version; + } + } catch { + // ignore + } + + try { + const cliPackageJsonUrl = new URL("../../../cli/package.json", import.meta.url); + const cliPackageJson = JSON.parse(readFileSync(fileURLToPath(cliPackageJsonUrl), "utf8")) as { version?: string }; + return cliPackageJson.version ?? "0.0.0-private"; + } catch { + return "0.0.0-private"; + } +} diff --git a/packages/new/src/utils/projectName.ts b/packages/new/src/utils/projectName.ts new file mode 100644 index 00000000..d5cb953a --- /dev/null +++ b/packages/new/src/utils/projectName.ts @@ -0,0 +1,56 @@ +import path from "node:path"; + +const TRAILING_SLASHES_REGEX = /\/+$/; +const PATH_SEPARATOR_REGEX = /\\/g; +const VALID_APP_NAME_REGEX = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; + +function normalizeProjectName(value: string) { + return value.replace(PATH_SEPARATOR_REGEX, "/"); +} + +function trimTrailingSlashes(value: string) { + return normalizeProjectName(value).replace(TRAILING_SLASHES_REGEX, ""); +} + +export function parseNameAndPath(projectName: string): [scopedAppName: string, appDir: string] { + const normalized = trimTrailingSlashes(projectName); + const segments = normalized.split("/"); + let scopedAppName = segments.at(-1) ?? ""; + + if (scopedAppName === ".") { + scopedAppName = path.basename(path.resolve(process.cwd())); + } + + const scopeIndex = segments.findIndex((segment) => segment.startsWith("@")); + if (scopeIndex !== -1) { + scopedAppName = segments.slice(scopeIndex).join("/"); + } + + const appDir = segments.filter((segment) => !segment.startsWith("@")).join("/"); + + return [scopedAppName, appDir]; +} + +export function validateAppName(projectName: string) { + const normalized = trimTrailingSlashes(projectName); + if (normalized === ".") { + const currentDirName = path.basename(path.resolve(process.cwd())); + return VALID_APP_NAME_REGEX.test(currentDirName) + ? undefined + : "Name must consist of only lowercase alphanumeric characters, '-', and '_'"; + } + + const segments = normalized.split("/"); + const scopeIndex = segments.findIndex((segment) => segment.startsWith("@")); + let scopedAppName = segments.at(-1); + + if (scopeIndex !== -1) { + scopedAppName = segments.slice(scopeIndex).join("/"); + } + + if (VALID_APP_NAME_REGEX.test(scopedAppName ?? "")) { + return; + } + + return "Name must consist of only lowercase alphanumeric characters, '-', and '_'"; +} diff --git a/packages/new/src/utils/prompts.ts b/packages/new/src/utils/prompts.ts new file mode 100644 index 00000000..5f977b7c --- /dev/null +++ b/packages/new/src/utils/prompts.ts @@ -0,0 +1,185 @@ +import { + intro as clackIntro, + isCancel as clackIsCancel, + log as clackLog, + note as clackNote, + outro as clackOutro, + spinner as clackSpinner, +} from "@clack/prompts"; +import { + checkbox as inquirerCheckbox, + confirm as inquirerConfirm, + input as inquirerInput, + password as inquirerPassword, + search as inquirerSearch, + select as inquirerSelect, +} from "@inquirer/prompts"; + +const CANCEL_SYMBOL = Symbol.for("@proofkit/new/prompt-cancelled"); + +export const intro = clackIntro; +export const log = clackLog; +export const note = clackNote; +export const outro = clackOutro; +export const spinner = clackSpinner; + +function isPromptCancel(error: unknown) { + return error instanceof Error && error.name === "ExitPromptError"; +} + +function withCancelSentinel(fn: () => Promise): Promise { + return fn().catch((error: unknown) => { + if (isPromptCancel(error)) { + return CANCEL_SYMBOL; + } + throw error; + }); +} + +export function isCancel(value: unknown): value is symbol { + return value === CANCEL_SYMBOL || clackIsCancel(value); +} + +export interface PromptOption { + value: T; + label: string; + hint?: string; + disabled?: boolean | string; +} + +export interface SearchPromptOption extends PromptOption { + keywords?: readonly string[]; +} + +function normalizeValidate( + validate: ((value: string) => string | undefined) | undefined, +): ((value: string) => string | boolean) | undefined { + if (!validate) { + return undefined; + } + + return (value: string) => validate(value) ?? true; +} + +function matchesSearch(option: SearchPromptOption, query: string) { + const haystack = [option.label, option.hint ?? "", ...(option.keywords ?? [])].join(" ").toLowerCase(); + return haystack.includes(query.trim().toLowerCase()); +} + +function normalizeDisabledMessage(value: boolean | string | undefined) { + if (typeof value === "string") { + return value; + } + return value ? true : undefined; +} + +export function filterSearchOptions( + options: readonly SearchPromptOption[], + query: string | undefined, +) { + const term = query?.trim(); + if (!term) { + return options; + } + + return options.filter((option) => matchesSearch(option, term)); +} + +export function textPrompt(options: { + message: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; +}) { + return withCancelSentinel(() => + inquirerInput({ + message: options.message, + default: options.defaultValue, + validate: normalizeValidate(options.validate), + }), + ); +} + +export function passwordPrompt(options: { message: string; validate?: (value: string) => string | undefined }) { + return withCancelSentinel(() => + inquirerPassword({ + message: options.message, + validate: normalizeValidate(options.validate), + }), + ); +} + +export function confirmPrompt(options: { message: string; initialValue?: boolean }) { + return withCancelSentinel(() => + inquirerConfirm({ + message: options.message, + default: options.initialValue, + }), + ); +} + +export function selectPrompt(options: { message: string; options: PromptOption[] }) { + return withCancelSentinel(() => + inquirerSelect({ + message: options.message, + pageSize: 10, + choices: options.options.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })), + }), + ); +} + +export function searchSelectPrompt(options: { + message: string; + emptyMessage?: string; + options: SearchPromptOption[]; +}) { + return withCancelSentinel(() => + inquirerSearch({ + message: options.message, + pageSize: 10, + source: (input) => { + const filtered = filterSearchOptions(options.options, input); + if (filtered.length === 0) { + return [ + { + value: "__no_matches__" as T, + name: options.emptyMessage ?? "No matches found. Keep typing to refine your search.", + disabled: options.emptyMessage ?? "No matches found", + }, + ]; + } + + return filtered.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })); + }, + }), + ); +} + +export function multiSearchSelectPrompt(options: { + message: string; + options: SearchPromptOption[]; + required?: boolean; +}) { + return withCancelSentinel(() => + inquirerCheckbox({ + message: options.message, + pageSize: 10, + required: options.required, + choices: options.options.map((option) => ({ + value: option.value, + name: option.label, + description: option.hint, + disabled: normalizeDisabledMessage(option.disabled), + })), + }), + ); +} diff --git a/packages/new/src/utils/renderTitle.ts b/packages/new/src/utils/renderTitle.ts new file mode 100644 index 00000000..7f1186c4 --- /dev/null +++ b/packages/new/src/utils/renderTitle.ts @@ -0,0 +1,19 @@ +import gradient from "gradient-string"; +import { getTitleText } from "~/consts.js"; +import { detectUserPackageManager } from "~/utils/packageManager.js"; + +const proofTheme = { + purple: "#89216B", + lightPurple: "#D15ABB", + orange: "#FF595E", +}; + +export const proofGradient = gradient(Object.values(proofTheme)); + +export function renderTitle(version = "0.0.0-private") { + const packageManager = detectUserPackageManager(); + if (packageManager === "yarn" || packageManager === "pnpm") { + console.log(""); + } + console.log(proofGradient.multiline(getTitleText(version))); +} diff --git a/packages/new/src/utils/versioning.ts b/packages/new/src/utils/versioning.ts new file mode 100644 index 00000000..3cdf9cd5 --- /dev/null +++ b/packages/new/src/utils/versioning.ts @@ -0,0 +1,7 @@ +export function getProofkitReleaseTag() { + return "beta"; +} + +export function getNodeMajorVersion() { + return process.versions.node.split(".")[0] ?? "22"; +} diff --git a/packages/new/tests/cli.test.ts b/packages/new/tests/cli.test.ts new file mode 100644 index 00000000..f4d634d2 --- /dev/null +++ b/packages/new/tests/cli.test.ts @@ -0,0 +1,70 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.join(__dirname, ".."); +const distEntry = path.join(packageDir, "dist/index.js"); + +function buildCli() { + execFileSync("pnpm", ["build"], { + cwd: packageDir, + stdio: "pipe", + encoding: "utf8", + }); +} + +describe("proofkit-new CLI", () => { + it("shows kebab-case init flags in help", () => { + buildCli(); + const output = execFileSync("node", [distEntry, "init", "--help"], { + cwd: packageDir, + stdio: "pipe", + encoding: "utf8", + }); + + expect(output).toContain("--app-type"); + expect(output).toContain("--non-interactive"); + expect(output).toContain("--no-install"); + expect(output).toContain("--no-git"); + expect(output).not.toContain("--appType"); + }); + + it("prints the header and coming-soon message when run inside a ProofKit project", async () => { + buildCli(); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-cli-project-")); + await fs.writeJson(path.join(cwd, "proofkit.json"), { + appType: "browser", + ui: "shadcn", + dataSources: [], + replacedMainPage: false, + registryTemplates: [], + }); + + const output = execFileSync("node", [distEntry], { + cwd, + stdio: "pipe", + encoding: "utf8", + }); + + expect(output).toContain("_______"); + expect(output).toContain("Found"); + expect(output).toContain("Coming soon"); + }); + + it("fails with guidance when no command is used in non-interactive mode", () => { + buildCli(); + const result = spawnSync("node", [distEntry, "--non-interactive"], { + cwd: packageDir, + stdio: "pipe", + encoding: "utf8", + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("interactive-only in non-interactive mode"); + expect(`${result.stdout}\n${result.stderr}`).toContain("proofkit-new init --non-interactive"); + }); +}); diff --git a/packages/new/tests/default-command.test.ts b/packages/new/tests/default-command.test.ts new file mode 100644 index 00000000..9f2e7ce8 --- /dev/null +++ b/packages/new/tests/default-command.test.ts @@ -0,0 +1,89 @@ +import os from "node:os"; +import path from "node:path"; +import { Effect } from "effect"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { runDefaultCommand } from "~/index.js"; +import { makeTestLayer } from "./test-layer.js"; + +function createConsoleTranscript() { + return { + info: [] as string[], + warn: [] as string[], + error: [] as string[], + success: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + }; +} + +describe("default command routing", () => { + it("routes to init when no ProofKit project is present", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-default-init-")); + const consoleTranscript = createConsoleTranscript(); + + await Effect.runPromise( + runDefaultCommand().pipe( + makeTestLayer({ + cwd, + packageManager: "pnpm", + nonInteractive: false, + console: consoleTranscript, + prompts: { + text: ["routed-app"], + select: ["browser", "none"], + }, + }), + ), + ); + + expect(await fs.pathExists(path.join(cwd, "routed-app", "proofkit.json"))).toBe(true); + expect(consoleTranscript.success.at(-1) ?? "").toContain("Created routed-app"); + expect(consoleTranscript.note.some((entry) => entry.title === "Coming soon")).toBe(false); + }); + + it("shows a coming-soon placeholder when a ProofKit project is present", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-default-project-")); + await fs.writeJson(path.join(cwd, "proofkit.json"), { + appType: "browser", + ui: "shadcn", + dataSources: [], + replacedMainPage: false, + registryTemplates: [], + }); + const consoleTranscript = createConsoleTranscript(); + + await Effect.runPromise( + runDefaultCommand().pipe( + makeTestLayer({ + cwd, + packageManager: "pnpm", + nonInteractive: false, + console: consoleTranscript, + }), + ), + ); + + expect(consoleTranscript.note).toEqual([ + { + title: "Coming soon", + message: expect.stringContaining("Project command routing is coming soon"), + }, + ]); + }); + + it("fails in non-interactive mode without an explicit command", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-default-ci-")); + + await expect( + Effect.runPromise( + runDefaultCommand({ nonInteractive: true }).pipe( + makeTestLayer({ + cwd, + packageManager: "pnpm", + nonInteractive: true, + }), + ), + ), + ).rejects.toThrow("interactive-only in non-interactive mode"); + }); +}); diff --git a/packages/new/tests/executor.test.ts b/packages/new/tests/executor.test.ts new file mode 100644 index 00000000..b5c8ded9 --- /dev/null +++ b/packages/new/tests/executor.test.ts @@ -0,0 +1,108 @@ +import os from "node:os"; +import path from "node:path"; +import { Effect } from "effect"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { executeInitPlan } from "~/core/executeInitPlan.js"; +import { planInit } from "~/core/planInit.js"; +import { getSharedTemplateDir, makeInitRequest, readScaffoldArtifacts } from "./init-fixtures.js"; +import { makeTestLayer } from "./test-layer.js"; + +describe("executeInitPlan command paths", () => { + it("runs install, git, codegen, and filemaker bootstrap through services", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-exec-")); + const tracker = { + commands: [] as string[], + gitInits: 0, + codegens: 0, + filemakerBootstraps: 0, + }; + + const plan = planInit( + makeInitRequest({ + projectName: "fm-app", + scopedAppName: "fm-app", + appDir: "fm-app", + appType: "webviewer", + ui: "shadcn", + dataSource: "filemaker", + packageManager: "pnpm", + noInstall: false, + noGit: false, + force: false, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: true, + fileMaker: { + mode: "hosted-otto", + dataSourceName: "filemaker", + envNames: { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }, + server: "https://example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_123", + layoutName: "API_Contacts", + schemaName: "Contacts", + }, + }), + { + templateDir: getSharedTemplateDir("vite-wv"), + }, + ); + + await Effect.runPromise(executeInitPlan(plan).pipe(makeTestLayer({ cwd, packageManager: "pnpm", tracker }))); + + expect(tracker.commands).toEqual(["pnpm install"]); + expect(tracker.filemakerBootstraps).toBe(1); + expect(tracker.codegens).toBe(1); + expect(tracker.gitInits).toBe(1); + + const { proofkitJson, envFile, typegenConfig } = await readScaffoldArtifacts(path.join(cwd, "fm-app")); + expect(proofkitJson.dataSources).toHaveLength(1); + expect(envFile).toContain("FM_DATABASE=Contacts.fmp12"); + expect(typegenConfig).toContain("API_Contacts"); + expect(typegenConfig).toContain("Contacts"); + }); + + it("supports force overwrite for an existing directory", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-force-")); + const projectDir = path.join(cwd, "force-app"); + await fs.ensureDir(projectDir); + await fs.writeFile(path.join(projectDir, "README.md"), "old content"); + + const plan = planInit( + makeInitRequest({ + projectName: "force-app", + scopedAppName: "force-app", + appDir: "force-app", + appType: "browser", + ui: "shadcn", + dataSource: "none", + packageManager: "pnpm", + noInstall: true, + noGit: true, + force: true, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: false, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + }, + ); + + await Effect.runPromise(executeInitPlan(plan).pipe(makeTestLayer({ cwd, packageManager: "pnpm" }))); + + expect(await fs.pathExists(path.join(projectDir, "README.md"))).toBe(true); + expect(await fs.readFile(path.join(projectDir, "README.md"), "utf8")).not.toBe("old content"); + }); +}); diff --git a/packages/new/tests/init-fixtures.ts b/packages/new/tests/init-fixtures.ts new file mode 100644 index 00000000..f155d0ae --- /dev/null +++ b/packages/new/tests/init-fixtures.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import fs from "fs-extra"; +import type { InitRequest } from "~/core/types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function makeInitRequest(overrides: Partial = {}): InitRequest { + return { + projectName: "demo-app", + scopedAppName: "demo-app", + appDir: "demo-app", + appType: "browser", + ui: "shadcn", + dataSource: "none", + packageManager: "pnpm", + noInstall: false, + noGit: false, + force: false, + cwd: "/tmp/workspace", + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: false, + ...overrides, + }; +} + +export function getSharedTemplateDir(templateName: "nextjs-shadcn" | "nextjs-mantine" | "vite-wv") { + return path.resolve(__dirname, `../../cli/template/${templateName}`); +} + +export async function readScaffoldArtifacts(projectDir: string) { + const packageJson = await fs.readJson(path.join(projectDir, "package.json")); + const proofkitJson = await fs.readJson(path.join(projectDir, "proofkit.json")); + const envFile = await fs.readFile(path.join(projectDir, ".env"), "utf8"); + const typegenPath = path.join(projectDir, "proofkit-typegen.config.jsonc"); + const typegenConfig = (await fs.pathExists(typegenPath)) ? await fs.readFile(typegenPath, "utf8") : undefined; + + return { + packageJson, + proofkitJson, + envFile, + typegenConfig, + }; +} diff --git a/packages/new/tests/integration.test.ts b/packages/new/tests/integration.test.ts new file mode 100644 index 00000000..55cfda34 --- /dev/null +++ b/packages/new/tests/integration.test.ts @@ -0,0 +1,212 @@ +import os from "node:os"; +import path from "node:path"; +import { Effect } from "effect"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { executeInitPlan } from "~/core/executeInitPlan.js"; +import { planInit } from "~/core/planInit.js"; +import { detectUserPackageManager } from "~/utils/packageManager.js"; +import { getSharedTemplateDir, makeInitRequest, readScaffoldArtifacts } from "./init-fixtures.js"; +import { makeTestLayer } from "./test-layer.js"; + +describe("integration scaffold generation", () => { + it("creates a browser scaffold with proofkit.json and env", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-browser-")); + const projectDir = path.join(cwd, "browser-app"); + const consoleTranscript = { + info: [] as string[], + warn: [] as string[], + error: [] as string[], + success: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + }; + const layer = makeTestLayer({ + cwd, + packageManager: detectUserPackageManager(), + console: consoleTranscript, + }); + + const plan = planInit( + makeInitRequest({ + projectName: "browser-app", + scopedAppName: "browser-app", + appDir: "browser-app", + appType: "browser", + ui: "shadcn", + dataSource: "none", + packageManager: "pnpm", + noInstall: true, + noGit: true, + force: false, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: false, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + packageManagerVersion: "10.27.0", + }, + ); + + await Effect.runPromise(layer(executeInitPlan(plan))); + + expect(await fs.pathExists(projectDir)).toBe(true); + expect(await fs.pathExists(path.join(projectDir, "package.json"))).toBe(true); + expect(await fs.pathExists(path.join(projectDir, "proofkit.json"))).toBe(true); + expect(await fs.pathExists(path.join(projectDir, ".env"))).toBe(true); + + const { packageJson, proofkitJson, envFile } = await readScaffoldArtifacts(projectDir); + + expect(packageJson.name).toBe("browser-app"); + expect(packageJson.packageManager).toBe("pnpm@10.27.0"); + expect(packageJson.proofkitMetadata).toMatchObject({ + scaffoldPackage: "@proofkit/new", + }); + expect(typeof packageJson.proofkitMetadata?.initVersion).toBe("string"); + expect(packageJson.proofkitMetadata?.initVersion).not.toBe(""); + expect(proofkitJson).toMatchObject({ + appType: "browser", + dataSources: [], + envFile: ".env", + }); + expect(envFile).toContain("# When adding additional environment variables"); + expect(consoleTranscript.success.at(-1) ?? "").toContain("Created browser-app"); + }); + + it("creates a webviewer scaffold without leaking state across runs", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-webviewer-")); + const firstDir = path.join(cwd, "first"); + const secondDir = path.join(cwd, "second"); + const layer = makeTestLayer({ + cwd, + packageManager: "pnpm", + }); + + const firstPlan = planInit( + makeInitRequest({ + projectName: "first", + scopedAppName: "first", + appDir: "first", + appType: "webviewer", + ui: "shadcn", + dataSource: "none", + packageManager: "pnpm", + noInstall: true, + noGit: true, + force: false, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: false, + }), + { + templateDir: getSharedTemplateDir("vite-wv"), + }, + ); + + const secondPlan = planInit( + makeInitRequest({ + projectName: "second", + scopedAppName: "second", + appDir: "second", + appType: "browser", + ui: "shadcn", + dataSource: "none", + packageManager: "pnpm", + noInstall: true, + noGit: true, + force: false, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: false, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + }, + ); + + await Effect.runPromise(layer(executeInitPlan(firstPlan))); + await Effect.runPromise(layer(executeInitPlan(secondPlan))); + + const firstSettings = await fs.readJson(path.join(firstDir, "proofkit.json")); + const secondSettings = await fs.readJson(path.join(secondDir, "proofkit.json")); + expect(firstSettings.appType).toBe("webviewer"); + expect(secondSettings.appType).toBe("browser"); + }); + + it("creates filemaker env and typegen config when explicit hosted inputs are provided", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-filemaker-")); + const layer = makeTestLayer({ + cwd, + packageManager: "pnpm", + }); + + const plan = planInit( + makeInitRequest({ + projectName: "filemaker-app", + scopedAppName: "filemaker-app", + appDir: "filemaker-app", + appType: "browser", + ui: "shadcn", + dataSource: "filemaker", + packageManager: "pnpm", + noInstall: true, + noGit: true, + force: false, + cwd, + importAlias: "~/", + nonInteractive: true, + debug: false, + skipFileMakerSetup: false, + hasExplicitFileMakerInputs: true, + fileMaker: { + mode: "hosted-otto", + dataSourceName: "filemaker", + envNames: { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }, + server: "https://example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_123", + layoutName: "API_Contacts", + schemaName: "Contacts", + }, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + }, + ); + + await Effect.runPromise(layer(executeInitPlan(plan))); + + const projectDir = path.join(cwd, "filemaker-app"); + const { proofkitJson, envFile, typegenConfig } = await readScaffoldArtifacts(projectDir); + + expect(proofkitJson.dataSources).toEqual([ + { + type: "fm", + name: "filemaker", + envNames: { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }, + }, + ]); + expect(envFile).toContain("FM_DATABASE=Contacts.fmp12"); + expect(envFile).toContain("FM_SERVER=https://example.com"); + expect(envFile).toContain("OTTO_API_KEY=dk_123"); + expect(typegenConfig).toContain("API_Contacts"); + expect(typegenConfig).toContain("Contacts"); + }); +}); diff --git a/packages/new/tests/planner.test.ts b/packages/new/tests/planner.test.ts new file mode 100644 index 00000000..f95f5a4f --- /dev/null +++ b/packages/new/tests/planner.test.ts @@ -0,0 +1,85 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { planInit } from "~/core/planInit.js"; +import { makeInitRequest } from "./init-fixtures.js"; + +describe("planInit", () => { + it("plans a browser scaffold", () => { + const plan = planInit(makeInitRequest(), { + templateDir: "/templates/browser", + packageManagerVersion: "10.0.0", + }); + + expect(plan.targetDir).toBe(path.resolve("/tmp/workspace", "demo-app")); + expect(plan.templateDir).toBe("/templates/browser"); + expect(plan.packageJson.name).toBe("demo-app"); + expect(plan.settings.appType).toBe("browser"); + expect(plan.tasks.runInstall).toBe(true); + expect(plan.tasks.initializeGit).toBe(true); + expect(plan.tasks.bootstrapFileMaker).toBe(false); + }); + + it("plans a webviewer scaffold with no install and no git", () => { + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "none", + noInstall: true, + noGit: true, + }), + { + templateDir: "/templates/webviewer", + }, + ); + + expect(plan.packageJson.dependencies["@proofkit/webviewer"]).toBe("beta"); + expect(plan.packageJson.devDependencies["@proofkit/typegen"]).toBe("beta"); + expect(plan.tasks.runInstall).toBe(false); + expect(plan.tasks.initializeGit).toBe(false); + }); + + it("plans filemaker bootstrap and initial codegen when inputs are explicit", () => { + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "filemaker", + hasExplicitFileMakerInputs: true, + fileMaker: { + mode: "hosted-otto", + dataSourceName: "filemaker", + envNames: { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }, + server: "https://example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_123", + layoutName: "API_Contacts", + schemaName: "Contacts", + }, + }), + { + templateDir: "/templates/webviewer", + }, + ); + + expect(plan.tasks.bootstrapFileMaker).toBe(true); + expect(plan.tasks.runInitialCodegen).toBe(true); + }); + + it("skips initial codegen for non-interactive webviewer runs without explicit inputs", () => { + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "filemaker", + }), + { + templateDir: "/templates/webviewer", + }, + ); + + expect(plan.tasks.bootstrapFileMaker).toBe(true); + expect(plan.tasks.runInitialCodegen).toBe(false); + }); +}); diff --git a/packages/new/tests/project-name.test.ts b/packages/new/tests/project-name.test.ts new file mode 100644 index 00000000..02dedf13 --- /dev/null +++ b/packages/new/tests/project-name.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseNameAndPath, validateAppName } from "~/utils/projectName.js"; + +describe("projectName utils", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("normalizes Windows-style separators when parsing the app name and directory", () => { + expect(parseNameAndPath("apps\\my-app")).toEqual(["my-app", "apps/my-app"]); + expect(parseNameAndPath(".\\my-app\\")).toEqual(["my-app", "./my-app"]); + }); + + it("validates the actual current directory name when projectName is '.'", () => { + vi.spyOn(process, "cwd").mockReturnValue("/tmp/My App"); + expect(validateAppName(".")).toBe("Name must consist of only lowercase alphanumeric characters, '-', and '_'"); + }); + + it("accepts '.' when the current directory name is valid", () => { + vi.spyOn(process, "cwd").mockReturnValue("/tmp/my-app"); + expect(validateAppName(".")).toBeUndefined(); + }); +}); diff --git a/packages/new/tests/prompts.test.ts b/packages/new/tests/prompts.test.ts new file mode 100644 index 00000000..fd7106cc --- /dev/null +++ b/packages/new/tests/prompts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { filterSearchOptions } from "~/utils/prompts.js"; + +describe("filterSearchOptions", () => { + const options = [ + { + value: "Contacts.fmp12", + label: "Contacts.fmp12", + hint: "open", + keywords: ["contacts", "reporting"], + }, + { + value: "Invoices.fmp12", + label: "Invoices.fmp12", + hint: "closed", + keywords: ["billing"], + disabled: "Already connected", + }, + ] as const; + + it("matches on labels, hints, and keywords", () => { + expect(filterSearchOptions(options, "reporting").map((option) => option.value)).toEqual(["Contacts.fmp12"]); + expect(filterSearchOptions(options, "closed").map((option) => option.value)).toEqual(["Invoices.fmp12"]); + }); + + it("returns all options when the search term is empty", () => { + expect(filterSearchOptions(options, "")).toEqual(options); + expect(filterSearchOptions(options, " ")).toEqual(options); + expect(filterSearchOptions(options, undefined)).toEqual(options); + }); + + it("returns an empty list when nothing matches", () => { + expect(filterSearchOptions(options, "missing")).toEqual([]); + }); +}); diff --git a/packages/new/tests/resolve-init.test.ts b/packages/new/tests/resolve-init.test.ts new file mode 100644 index 00000000..642fb480 --- /dev/null +++ b/packages/new/tests/resolve-init.test.ts @@ -0,0 +1,175 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { resolveInitRequest } from "~/core/resolveInitRequest.js"; +import { makeTestLayer } from "./test-layer.js"; + +describe("resolveInitRequest", () => { + it("fails for missing project name in non-interactive mode", async () => { + await expect( + Effect.runPromise( + resolveInitRequest(undefined, { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: true, + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + }), + ), + ), + ).rejects.toThrow("Project name is required in non-interactive mode."); + }); + + it("fails for incomplete non-interactive filemaker inputs", async () => { + await expect( + Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: true, + appType: "browser", + dataSource: "filemaker", + server: "https://example.com", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + }), + ), + ), + ).rejects.toThrow("--file-name, --data-api-key"); + }); + + it("fails when only one of layout-name and schema-name is provided", async () => { + await expect( + Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: true, + appType: "browser", + dataSource: "filemaker", + layoutName: "API_Contacts", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + }), + ), + ), + ).rejects.toThrow("Both --layout-name and --schema-name must be provided together."); + }); + + it("resolves an interactive filemaker request from prompt responses", async () => { + const request = await Effect.runPromise( + resolveInitRequest(undefined, { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: false, + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + nonInteractive: false, + prompts: { + text: ["interactive-app", "https://fm.example.com", "reportingContacts"], + select: ["webviewer", "hosted"], + searchSelect: ["Contacts.fmp12", "dk_existing", "API_Contacts"], + confirm: [true], + }, + }), + ), + ); + + expect(request.projectName).toBe("interactive-app"); + expect(request.appType).toBe("webviewer"); + expect(request.dataSource).toBe("filemaker"); + expect(request.fileMaker).toMatchObject({ + mode: "hosted-otto", + server: "https://fm.example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_existing", + schemaName: "reportingContacts", + }); + }); + + it("marks explicit filemaker inputs in non-interactive mode", async () => { + const request = await Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: true, + appType: "webviewer", + dataSource: "filemaker", + server: "https://fm.example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_123", + layoutName: "API_Contacts", + schemaName: "Contacts", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + }), + ), + ); + + expect(request.hasExplicitFileMakerInputs).toBe(true); + expect(request.fileMaker).toMatchObject({ + mode: "hosted-otto", + server: "https://fm.example.com", + fileName: "Contacts.fmp12", + dataApiKey: "dk_123", + layoutName: "API_Contacts", + schemaName: "Contacts", + }); + }); + + it("uses local fm http for webviewer setup when available", async () => { + const request = await Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: false, + importAlias: "~/", + CI: false, + appType: "webviewer", + dataSource: "filemaker", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "pnpm", + nonInteractive: false, + fileMaker: { + localFmHttp: { + healthy: true, + connectedFiles: ["LocalFile.fmp12"], + }, + }, + }), + ), + ); + + expect(request.fileMaker).toMatchObject({ + mode: "local-fm-http", + fileName: "LocalFile.fmp12", + }); + }); +}); diff --git a/packages/new/tests/test-layer.ts b/packages/new/tests/test-layer.ts new file mode 100644 index 00000000..acee2768 --- /dev/null +++ b/packages/new/tests/test-layer.ts @@ -0,0 +1,347 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import fs from "fs-extra"; +import { + CliContext, + CodegenService, + ConsoleService, + type FileMakerBootstrapArtifacts, + FileMakerService, + FileSystemService, + GitService, + PackageManagerService, + ProcessService, + PromptService, + SettingsService, + TemplateService, +} from "~/core/context.js"; +import type { AppType, FileMakerInputs, ProofKitSettings, UIType } from "~/core/types.js"; +import type { PackageManager } from "~/utils/packageManager.js"; +import { createDataSourceEnvNames, updateTypegenConfig } from "~/utils/projectFiles.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export interface PromptScript { + text?: string[]; + select?: string[]; + confirm?: boolean[]; + password?: string[]; + searchSelect?: string[]; + multiSearchSelect?: string[][]; +} + +export interface ConsoleTranscript { + info: string[]; + warn: string[]; + error: string[]; + success: string[]; + note: Array<{ message: string; title?: string }>; +} + +export function makeTestLayer(options: { + cwd: string; + packageManager: PackageManager; + nonInteractive?: boolean; + prompts?: PromptScript; + console?: ConsoleTranscript; + tracker?: { + commands: string[]; + gitInits: number; + codegens: number; + filemakerBootstraps: number; + }; + fileMaker?: { + localFmHttp?: { + healthy: boolean; + baseUrl?: string; + connectedFiles?: string[]; + }; + }; +}) { + const tracker = options.tracker; + const promptScript = { + text: [...(options.prompts?.text ?? [])], + select: [...(options.prompts?.select ?? [])], + confirm: [...(options.prompts?.confirm ?? [])], + password: [...(options.prompts?.password ?? [])], + searchSelect: [...(options.prompts?.searchSelect ?? [])], + multiSearchSelect: [...(options.prompts?.multiSearchSelect ?? [])], + }; + const consoleTranscript = options.console; + + const layer = Layer.mergeAll( + Layer.succeed(CliContext, { + cwd: options.cwd, + debug: false, + nonInteractive: options.nonInteractive ?? true, + packageManager: options.packageManager, + }), + Layer.succeed(PromptService, { + text: ({ defaultValue }: { defaultValue?: string }) => { + const next = promptScript.text.shift(); + return Promise.resolve(next ?? defaultValue ?? "value"); + }, + password: () => Promise.resolve(promptScript.password.shift() ?? "password"), + select: ({ options }: { options: { value: T }[] }) => { + const next = promptScript.select.shift(); + if (next) { + const match = options.find((option) => option.value === next); + if (match) { + return Promise.resolve(match.value); + } + } + return Promise.resolve(options[0]?.value ?? ("" as T)); + }, + searchSelect: ({ options }: { options: { value: T }[] }) => { + const next = promptScript.searchSelect.shift(); + if (next) { + const match = options.find((option) => option.value === next); + if (match) { + return Promise.resolve(match.value); + } + } + return Promise.resolve(options[0]?.value ?? ("" as T)); + }, + multiSearchSelect: ({ options }: { options: { value: T }[] }) => { + const next = promptScript.multiSearchSelect.shift(); + if (next) { + return Promise.resolve(next.filter((value): value is T => options.some((option) => option.value === value))); + } + return Promise.resolve(options.slice(0, 1).map((option) => option.value)); + }, + confirm: async ({ initialValue }: { initialValue?: boolean }) => + promptScript.confirm.shift() ?? initialValue ?? false, + }), + Layer.succeed(ConsoleService, { + info: (message: string) => { + consoleTranscript?.info.push(message); + }, + warn: (message: string) => { + consoleTranscript?.warn.push(message); + }, + error: (message: string) => { + consoleTranscript?.error.push(message); + }, + success: (message: string) => { + consoleTranscript?.success.push(message); + }, + note: (message: string, title?: string) => { + consoleTranscript?.note.push({ message, title }); + }, + }), + Layer.succeed(FileSystemService, { + exists: async (targetPath: string) => fs.pathExists(targetPath), + readdir: async (targetPath: string) => fs.readdir(targetPath), + emptyDir: async (targetPath: string) => fs.emptyDir(targetPath), + copyDir: async (from: string, to: string, opts?: { overwrite?: boolean }) => + fs.copy(from, to, { overwrite: opts?.overwrite ?? true }), + rename: async (from: string, to: string) => fs.rename(from, to), + remove: async (targetPath: string) => fs.remove(targetPath), + readJson: async (targetPath: string) => fs.readJson(targetPath) as Promise, + writeJson: async (targetPath: string, value: unknown) => fs.writeJson(targetPath, value, { spaces: 2 }), + writeFile: async (targetPath: string, content: string) => fs.writeFile(targetPath, content, "utf8"), + readFile: async (targetPath: string) => fs.readFile(targetPath, "utf8"), + }), + Layer.succeed(TemplateService, { + getTemplateDir: (appType: AppType, ui: UIType) => { + let templateName = "nextjs-shadcn"; + if (appType === "webviewer") { + templateName = "vite-wv"; + } else if (ui === "mantine") { + templateName = "nextjs-mantine"; + } + return path.resolve(__dirname, `../../cli/template/${templateName}`); + }, + }), + Layer.succeed(PackageManagerService, { + getVersion: async () => "10.27.0", + }), + Layer.succeed(ProcessService, { + run: (command: string, args: string[]) => { + tracker?.commands.push([command, ...args].join(" ")); + return Promise.resolve({ stdout: "", stderr: "" }); + }, + }), + Layer.succeed(GitService, { + initialize: () => { + if (tracker) { + tracker.gitInits += 1; + } + return Promise.resolve(); + }, + }), + Layer.succeed(SettingsService, { + writeSettings: async (projectDir: string, settings: ProofKitSettings) => + fs.writeJson(path.join(projectDir, "proofkit.json"), settings, { spaces: 2 }), + appendEnvVars: async (projectDir: string, vars: Record) => { + const envPath = path.join(projectDir, ".env"); + const existing = (await fs.pathExists(envPath)) ? await fs.readFile(envPath, "utf8") : ""; + const additions = Object.entries(vars) + .map(([name, value]) => `${name}=${value}`) + .join("\n"); + await fs.writeFile(envPath, [existing.trimEnd(), additions].filter(Boolean).join("\n").concat("\n"), "utf8"); + }, + ensureTypegenConfig: async (projectDir: string, options: { appType: AppType; fileMaker?: FileMakerInputs }) => { + const typegenPath = path.join(projectDir, "proofkit-typegen.config.jsonc"); + if (!(await fs.pathExists(typegenPath))) { + await fs.writeFile(typegenPath, `${JSON.stringify({ config: { layouts: [] } }, null, 2)}\n`, "utf8"); + } + if (options.fileMaker?.layoutName && options.fileMaker?.schemaName) { + const parsed = JSON.parse(await fs.readFile(typegenPath, "utf8")) as { + config: + | { layouts?: Array<{ layoutName: string; schemaName: string }> } + | Array<{ layouts?: Array<{ layoutName: string; schemaName: string }> }>; + }; + let layouts: Array<{ layoutName: string; schemaName: string }>; + if (Array.isArray(parsed.config)) { + const firstConfig = parsed.config[0] ?? {}; + firstConfig.layouts ??= []; + parsed.config[0] = firstConfig; + layouts = firstConfig.layouts; + } else { + parsed.config.layouts ??= []; + layouts = parsed.config.layouts; + } + layouts.push({ + layoutName: options.fileMaker.layoutName, + schemaName: options.fileMaker.schemaName, + }); + await fs.writeFile(typegenPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); + } + }, + }), + Layer.succeed(FileMakerService, { + detectLocalFmHttp: async () => ({ + baseUrl: options.fileMaker?.localFmHttp?.baseUrl ?? "http://127.0.0.1:1365", + healthy: options.fileMaker?.localFmHttp?.healthy ?? false, + connectedFiles: options.fileMaker?.localFmHttp?.connectedFiles ?? [], + }), + validateHostedServerUrl: async (serverUrl: string) => ({ + normalizedUrl: serverUrl, + versions: { + fmsVersion: "21.0.0", + ottoVersion: "4.8.0", + }, + }), + getOttoFMSToken: async () => ({ token: "admin_token" }), + listFiles: async () => [{ filename: "Contacts.fmp12", status: "open" }], + listAPIKeys: async () => [ + { + key: "dk_existing", + user: "Admin", + database: "Contacts.fmp12", + label: "Existing key", + }, + ], + createDataAPIKeyWithCredentials: async () => ({ apiKey: "dk_created" }), + deployDemoFile: async () => ({ apiKey: "dk_demo", filename: "ProofKitDemo.fmp12" }), + listLayouts: async () => ["API_Contacts", "Contacts"], + createFileMakerBootstrapArtifacts: ( + settings: ProofKitSettings, + inputs: FileMakerInputs, + appType: AppType, + ): Promise => { + const envNames = createDataSourceEnvNames("filemaker"); + return Promise.resolve({ + settings: { + ...settings, + dataSources: [ + ...settings.dataSources, + { + type: "fm", + name: "filemaker", + envNames, + }, + ], + }, + envVars: + inputs.mode === "hosted-otto" + ? { + [envNames.database]: inputs.fileName, + [envNames.server]: inputs.server, + [envNames.apiKey]: inputs.dataApiKey, + } + : {}, + envSchemaEntries: + inputs.mode === "hosted-otto" + ? [ + { + name: envNames.database, + zodSchema: 'z.string().endsWith(".fmp12")', + defaultValue: inputs.fileName, + }, + { name: envNames.server, zodSchema: "z.string().url()", defaultValue: inputs.server }, + { name: envNames.apiKey, zodSchema: 'z.string().startsWith("dk_")', defaultValue: inputs.dataApiKey }, + ] + : [], + typegenConfig: { + mode: inputs.mode, + dataSourceName: "filemaker", + envNames: inputs.mode === "hosted-otto" ? envNames : undefined, + fmHttpBaseUrl: inputs.mode === "local-fm-http" ? inputs.fmHttpBaseUrl : undefined, + connectedFileName: inputs.mode === "local-fm-http" ? inputs.fileName : undefined, + layoutName: inputs.layoutName, + schemaName: inputs.schemaName, + appType, + }, + }); + }, + bootstrap: async (projectDir: string, settings: ProofKitSettings, inputs: FileMakerInputs, appType: AppType) => { + if (tracker) { + tracker.filemakerBootstraps += 1; + } + const nextSettings: ProofKitSettings = { + ...settings, + dataSources: [ + ...settings.dataSources, + { + type: "fm", + name: "filemaker", + envNames: { + database: "FM_DATABASE", + server: "FM_SERVER", + apiKey: "OTTO_API_KEY", + }, + }, + ], + }; + if (inputs.mode === "hosted-otto") { + const envPath = path.join(projectDir, ".env"); + const content = (await fs.readFile(envPath, "utf8")).concat( + `FM_DATABASE=${inputs.fileName}\nFM_SERVER=${inputs.server}\nOTTO_API_KEY=${inputs.dataApiKey}\n`, + ); + await fs.writeFile(envPath, content, "utf8"); + } + await updateTypegenConfig( + { + exists: async (targetPath: string) => fs.pathExists(targetPath), + readFile: async (targetPath: string) => fs.readFile(targetPath, "utf8"), + writeFile: async (targetPath: string, content: string) => fs.writeFile(targetPath, content, "utf8"), + }, + projectDir, + { + appType, + dataSourceName: "filemaker", + envNames: inputs.mode === "hosted-otto" ? createDataSourceEnvNames("filemaker") : undefined, + fmHttpBaseUrl: inputs.mode === "local-fm-http" ? inputs.fmHttpBaseUrl : undefined, + connectedFileName: inputs.mode === "local-fm-http" ? inputs.fileName : undefined, + layoutName: inputs.layoutName, + schemaName: inputs.schemaName, + }, + ); + return nextSettings; + }, + }), + Layer.succeed(CodegenService, { + runInitial: () => { + if (tracker) { + tracker.codegens += 1; + } + return Promise.resolve(); + }, + }), + ); + + return (effect: Effect.Effect) => Effect.provide(effect, layer); +} diff --git a/packages/new/tsconfig.json b/packages/new/tsconfig.json new file mode 100644 index 00000000..9d98475f --- /dev/null +++ b/packages/new/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~/*": ["./src/*"] + }, + "strictNullChecks": true + }, + "exclude": ["template", "dist"], + "include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"] +} diff --git a/packages/new/tsdown.config.ts b/packages/new/tsdown.config.ts new file mode 100644 index 00000000..07586488 --- /dev/null +++ b/packages/new/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +const isDev = process.env.npm_lifecycle_event === "dev"; + +export default defineConfig({ + clean: true, + entry: ["src/index.ts"], + format: ["esm"], + minify: !isDev, + target: "esnext", + outDir: "dist", + nodeProtocol: false, + onSuccess: isDev ? "node dist/index.js" : undefined, +}); diff --git a/packages/new/vitest.config.ts b/packages/new/vitest.config.ts new file mode 100644 index 00000000..68059ee9 --- /dev/null +++ b/packages/new/vitest.config.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const configDir = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "~": path.resolve(configDir, "src"), + }, + }, + test: { + globals: true, + environment: "node", + include: ["tests/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + testTimeout: 60_000, + }, +}); diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts index 6c1abcac..6c8456fb 100644 --- a/packages/typegen/src/server/createDataApiClient.ts +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -242,7 +242,7 @@ export function createClientFromConfig(config: FmdapiConfig): Omit=16.8.0' + '@effect/cli@0.74.0': + resolution: {integrity: sha512-vjMJWJWQ2zMRVcZJj2ZGr7vFgVoX6lsCuqAsNiN2ndWZAidkEJ6g1Euuib2V2nTXeWvRyd3FY2Fw2UvX48Uenw==} + peerDependencies: + '@effect/platform': ^0.95.0 + '@effect/printer': ^0.48.0 + '@effect/printer-ansi': ^0.48.0 + effect: ^3.20.0 + + '@effect/cluster@0.57.0': + resolution: {integrity: sha512-VjZoZ4hmgDb0GtGjktypTk/nArA3ntsXU2O9vOBzDjJLRKVBt7IS0/cllHrHwK5Jxkfz86B2k+Prw4/+nrLFlw==} + peerDependencies: + '@effect/platform': ^0.95.0 + '@effect/rpc': ^0.74.0 + '@effect/sql': ^0.50.0 + '@effect/workflow': ^0.17.0 + effect: ^3.20.0 + + '@effect/experimental@0.59.0': + resolution: {integrity: sha512-XqdBpIH5VLlkRxKlyPYp8TAYUeBPjoWYgtrxDebDab14K4kkrpkHk0ZsmmOiQUZ+LY5veRn/PBSogXor9gtPqg==} + peerDependencies: + '@effect/platform': ^0.95.0 + effect: ^3.20.0 + ioredis: ^5 + lmdb: ^3 + peerDependenciesMeta: + ioredis: + optional: true + lmdb: + optional: true + + '@effect/platform-node-shared@0.58.0': + resolution: {integrity: sha512-kl8ejYM1xvjRlk+4/R1YzB6A3E3hVWY4jIfEl21uu4S43V0S15gHvcur7iMIEXfJTX1a25EKF+Buef+Yv5wZZQ==} + peerDependencies: + '@effect/cluster': ^0.57.0 + '@effect/platform': ^0.95.0 + '@effect/rpc': ^0.74.0 + '@effect/sql': ^0.50.0 + effect: ^3.20.0 + + '@effect/platform-node@0.105.0': + resolution: {integrity: sha512-6JxOLqLJMm+m1ZQavIb75S7YJ4fRvrDaYUZ4rqv2IMq5ZK9HVaU/LeejE9tip9zAG9yNM/6mn183iiIV/xge5w==} + peerDependencies: + '@effect/cluster': ^0.57.0 + '@effect/platform': ^0.95.0 + '@effect/rpc': ^0.74.0 + '@effect/sql': ^0.50.0 + effect: ^3.20.0 + + '@effect/platform@0.95.0': + resolution: {integrity: sha512-WDlRiWRSWlmhCPq09bvAofK0qr5vM4yNklXjoJdZHmugKRRTpN/Okn3ODnjgM/Kb/4hjMrRyrsUeH/Brieq7KA==} + peerDependencies: + effect: ^3.20.0 + + '@effect/printer-ansi@0.48.0': + resolution: {integrity: sha512-CzQ5kiomjR9DZ6LPfKAaWmys6JU65c2Q/VQcTKRK4RfaDWeTAehpAVmgOIyKSPkcr9XBhjo2cJx4xyZ4E5nN7g==} + peerDependencies: + '@effect/typeclass': ^0.39.0 + effect: ^3.20.0 + + '@effect/printer@0.48.0': + resolution: {integrity: sha512-f/+QVyqACuLkoB+HDDX2XxloslmgMDL+C6ecHBV0cB0zJzJmLCOybwOkRcCI2xJ/DWHEIpoRyvq+Bfdza0AIrA==} + peerDependencies: + '@effect/typeclass': ^0.39.0 + effect: ^3.20.0 + + '@effect/rpc@0.74.0': + resolution: {integrity: sha512-EV/cHQqJxLtY+RTlPlVQU1KyTzml1wFne+Sh91RacGRRVh6uTm4UdhRh9TNtbYHD4rM9yD3T6zqUgKr0AH8MvQ==} + peerDependencies: + '@effect/platform': ^0.95.0 + effect: ^3.20.0 + + '@effect/sql@0.50.0': + resolution: {integrity: sha512-sOTzsC+ICASgSmX1RITYo6ut7ZbkX+hMG6YagJEyhtptxco9MgSflpF/ix/L92haJ+YTS5Zur/Dm2bDNfVes4w==} + peerDependencies: + '@effect/experimental': ^0.59.0 + '@effect/platform': ^0.95.0 + effect: ^3.20.0 + + '@effect/typeclass@0.39.0': + resolution: {integrity: sha512-V8qGpm4BTMS4pW9e7aCdxC0sy/TYsdxmnpWtokkNWnggZ6kvh1Psp3AfUuuZLyNmUk4T+lYB/ItEsga/+hryig==} + peerDependencies: + effect: ^3.20.0 + + '@effect/workflow@0.17.0': + resolution: {integrity: sha512-JiayvFTTMrp36P0cVFcgu6Nb7ZJxQv+FRqs3DPORkVAcCZlWOKa3KyuYebN3qZbRsmLzS7cxuC8BAeMuqb+WaQ==} + peerDependencies: + '@effect/experimental': ^0.59.0 + '@effect/platform': ^0.95.0 + '@effect/rpc': ^0.74.0 + effect: ^3.20.0 + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -2178,6 +2357,19 @@ packages: resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} + '@inquirer/ansi@2.0.4': + resolution: {integrity: sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.1.2': + resolution: {integrity: sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/confirm@5.1.21': resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} @@ -2187,6 +2379,15 @@ packages: '@types/node': optional: true + '@inquirer/confirm@6.0.10': + resolution: {integrity: sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@10.3.2': resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} @@ -2196,6 +2397,33 @@ packages: '@types/node': optional: true + '@inquirer/core@11.1.7': + resolution: {integrity: sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.0.10': + resolution: {integrity: sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.10': + resolution: {integrity: sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -2205,10 +2433,86 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@2.0.4': + resolution: {integrity: sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@1.0.15': resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} + '@inquirer/figures@2.0.4': + resolution: {integrity: sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.10': + resolution: {integrity: sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.10': + resolution: {integrity: sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.10': + resolution: {integrity: sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.3.2': + resolution: {integrity: sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.2.6': + resolution: {integrity: sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.1.6': + resolution: {integrity: sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.1.2': + resolution: {integrity: sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/type@3.0.10': resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} @@ -2218,6 +2522,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.0.4': + resolution: {integrity: sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -2364,6 +2677,36 @@ packages: '@mongodb-js/saslprep@1.4.6': resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -2604,6 +2947,88 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@planetscale/database@1.19.0': resolution: {integrity: sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA==} engines: {node: '>=16'} @@ -5251,9 +5676,18 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-xml-parser@5.3.3: resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} hasBin: true @@ -5295,6 +5729,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -5688,6 +6125,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5940,6 +6381,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + kysely@0.28.12: resolution: {integrity: sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw==} engines: {node: '>=20.0.0'} @@ -6334,6 +6778,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -6432,6 +6881,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.9: + resolution: {integrity: sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==} + msw@2.12.7: resolution: {integrity: sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==} engines: {node: '>=18'} @@ -6445,10 +6901,17 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mysql2@3.16.0: resolution: {integrity: sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==} engines: {node: '>= 8.0'} @@ -6531,6 +6994,9 @@ packages: resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} engines: {node: '>=10'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6547,6 +7013,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -7572,6 +8042,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -7792,6 +8265,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -8774,6 +9251,102 @@ snapshots: react: 19.2.3 tslib: 2.8.1 + '@effect/cli@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/printer-ansi@0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0))(@effect/printer@0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/platform': 0.95.0(effect@3.20.0) + '@effect/printer': 0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0) + '@effect/printer-ansi': 0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0) + effect: 3.20.0 + ini: 4.1.3 + toml: 3.0.0 + yaml: 2.8.2 + + '@effect/cluster@0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/platform': 0.95.0(effect@3.20.0) + '@effect/rpc': 0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/sql': 0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/workflow': 0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0) + effect: 3.20.0 + kubernetes-types: 1.30.0 + + '@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/platform': 0.95.0(effect@3.20.0) + effect: 3.20.0 + uuid: 11.1.0 + + '@effect/platform-node-shared@0.58.0(@effect/cluster@0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/cluster': 0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0) + '@effect/platform': 0.95.0(effect@3.20.0) + '@effect/rpc': 0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/sql': 0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@parcel/watcher': 2.5.6 + effect: 3.20.0 + multipasta: 0.2.7 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@0.105.0(@effect/cluster@0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/cluster': 0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0) + '@effect/platform': 0.95.0(effect@3.20.0) + '@effect/platform-node-shared': 0.58.0(@effect/cluster@0.57.0(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0) + '@effect/rpc': 0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/sql': 0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + effect: 3.20.0 + mime: 3.0.0 + undici: 7.24.4 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform@0.95.0(effect@3.20.0)': + dependencies: + effect: 3.20.0 + find-my-way-ts: 0.1.6 + msgpackr: 1.11.9 + multipasta: 0.2.7 + + '@effect/printer-ansi@0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/printer': 0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0) + '@effect/typeclass': 0.39.0(effect@3.20.0) + effect: 3.20.0 + + '@effect/printer@0.48.0(@effect/typeclass@0.39.0(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/typeclass': 0.39.0(effect@3.20.0) + effect: 3.20.0 + + '@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/platform': 0.95.0(effect@3.20.0) + effect: 3.20.0 + msgpackr: 1.11.9 + + '@effect/sql@0.50.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/experimental': 0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/platform': 0.95.0(effect@3.20.0) + effect: 3.20.0 + uuid: 11.1.0 + + '@effect/typeclass@0.39.0(effect@3.20.0)': + dependencies: + effect: 3.20.0 + + '@effect/workflow@0.17.0(@effect/experimental@0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(@effect/platform@0.95.0(effect@3.20.0))(@effect/rpc@0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0))(effect@3.20.0)': + dependencies: + '@effect/experimental': 0.59.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + '@effect/platform': 0.95.0(effect@3.20.0) + '@effect/rpc': 0.74.0(@effect/platform@0.95.0(effect@3.20.0))(effect@3.20.0) + effect: 3.20.0 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -9119,9 +9692,9 @@ snapshots: '@formatjs/fast-memoize': 3.0.3 tslib: 2.8.1 - '@fumadocs/ui@16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5)': + '@fumadocs/ui@16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5)': dependencies: - fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) + fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) postcss-selector-parser: 7.1.1 react: 19.2.3 @@ -9264,6 +9837,17 @@ snapshots: '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.4': {} + + '@inquirer/checkbox@5.1.2(@types/node@22.19.5)': + dependencies: + '@inquirer/ansi': 2.0.4 + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + '@inquirer/confirm@5.1.21(@types/node@22.19.5)': dependencies: '@inquirer/core': 10.3.2(@types/node@22.19.5) @@ -9279,6 +9863,13 @@ snapshots: '@types/node': 25.0.6 optional: true + '@inquirer/confirm@6.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + '@inquirer/core@10.3.2(@types/node@22.19.5)': dependencies: '@inquirer/ansi': 1.0.2 @@ -9306,6 +9897,33 @@ snapshots: '@types/node': 25.0.6 optional: true + '@inquirer/core@11.1.7(@types/node@22.19.5)': + dependencies: + '@inquirer/ansi': 2.0.4 + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@22.19.5) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/editor@5.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/external-editor': 2.0.4(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/expand@5.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + '@inquirer/external-editor@1.0.3(@types/node@22.19.5)': dependencies: chardet: 2.1.1 @@ -9313,8 +9931,78 @@ snapshots: optionalDependencies: '@types/node': 22.19.5 + '@inquirer/external-editor@2.0.4(@types/node@22.19.5)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.5 + '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.4': {} + + '@inquirer/input@5.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/number@4.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/password@5.0.10(@types/node@22.19.5)': + dependencies: + '@inquirer/ansi': 2.0.4 + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/prompts@8.3.2(@types/node@22.19.5)': + dependencies: + '@inquirer/checkbox': 5.1.2(@types/node@22.19.5) + '@inquirer/confirm': 6.0.10(@types/node@22.19.5) + '@inquirer/editor': 5.0.10(@types/node@22.19.5) + '@inquirer/expand': 5.0.10(@types/node@22.19.5) + '@inquirer/input': 5.0.10(@types/node@22.19.5) + '@inquirer/number': 4.0.10(@types/node@22.19.5) + '@inquirer/password': 5.0.10(@types/node@22.19.5) + '@inquirer/rawlist': 5.2.6(@types/node@22.19.5) + '@inquirer/search': 4.1.6(@types/node@22.19.5) + '@inquirer/select': 5.1.2(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/rawlist@5.2.6(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/search@4.1.6(@types/node@22.19.5)': + dependencies: + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + + '@inquirer/select@5.1.2(@types/node@22.19.5)': + dependencies: + '@inquirer/ansi': 2.0.4 + '@inquirer/core': 11.1.7(@types/node@22.19.5) + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@22.19.5) + optionalDependencies: + '@types/node': 22.19.5 + '@inquirer/type@3.0.10(@types/node@22.19.5)': optionalDependencies: '@types/node': 22.19.5 @@ -9324,6 +10012,10 @@ snapshots: '@types/node': 25.0.6 optional: true + '@inquirer/type@4.0.4(@types/node@22.19.5)': + optionalDependencies: + '@types/node': 22.19.5 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -9605,6 +10297,24 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -9766,6 +10476,66 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@planetscale/database@1.19.0': {} '@polka/url@1.0.0-next.29': {} @@ -11329,6 +12099,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.1.9(vitest@4.0.17(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 4.0.17(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@2.1.9(vitest@4.0.17(@types/node@25.0.6)(happy-dom@20.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 @@ -12555,8 +13343,18 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fast-xml-parser@5.3.3: dependencies: strnum: 2.1.2 @@ -12602,6 +13400,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way-ts@0.1.6: {} + find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -12664,7 +13464,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5): + fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5): dependencies: '@formatjs/intl-localematcher': 0.7.5 '@orama/orama': 3.1.18 @@ -12695,7 +13495,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5): + fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5): dependencies: '@formatjs/intl-localematcher': 0.7.5 '@orama/orama': 3.1.18 @@ -12726,14 +13526,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.4(@types/react@19.2.7)(fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@6.4.1(@types/node@22.19.5)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): + fumadocs-mdx@14.2.4(@types/react@19.2.7)(fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@6.4.1(@types/node@22.19.5)(jiti@1.21.7)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) + fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) js-yaml: 4.1.1 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 @@ -12754,11 +13554,11 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-twoslash@3.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + fumadocs-twoslash@3.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): dependencies: '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@shikijs/twoslash': 3.21.0(typescript@5.9.3) - fumadocs-ui: 16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) + fumadocs-ui: 16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) mdast-util-from-markdown: 2.0.2 mdast-util-gfm: 3.1.0 mdast-util-to-hast: 13.2.1 @@ -12774,10 +13574,10 @@ snapshots: - supports-color - typescript - fumadocs-typescript@5.0.1(@types/react@19.2.7)(fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5))(react@19.2.3)(typescript@5.9.3): + fumadocs-typescript@5.0.1(@types/react@19.2.7)(fumadocs-core@16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5))(react@19.2.3)(typescript@5.9.3): dependencies: estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) + fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.511.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 react: 19.2.3 @@ -12789,13 +13589,13 @@ snapshots: unist-util-visit: 5.0.0 optionalDependencies: '@types/react': 19.2.7 - fumadocs-ui: 16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) + fumadocs-ui: 16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) transitivePeerDependencies: - supports-color - fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5): + fumadocs-ui@16.4.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5): dependencies: - '@fumadocs/ui': 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) + '@fumadocs/ui': 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -12807,7 +13607,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: 0.7.1 - fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) + fumadocs-core: 16.4.4(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) lucide-react: 0.562.0(react@19.2.3) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 @@ -13093,6 +13893,8 @@ snapshots: ini@1.3.8: optional: true + ini@4.1.3: {} + inline-style-parser@0.2.7: {} ipaddr.js@1.9.1: {} @@ -13316,6 +14118,8 @@ snapshots: kolorist@1.8.0: {} + kubernetes-types@1.30.0: {} + kysely@0.28.12: {} libsql@0.3.19: @@ -13972,6 +14776,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -14038,6 +14844,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.9: + optionalDependencies: + msgpackr-extract: 3.0.3 + msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@22.19.5) @@ -14091,8 +14913,12 @@ snapshots: muggle-string@0.4.1: {} + multipasta@0.2.7: {} + mute-stream@2.0.0: {} + mute-stream@3.0.0: {} + mysql2@3.16.0: dependencies: aws-ssl-profiles: 1.1.2 @@ -14179,6 +15005,8 @@ snapshots: semver: 7.7.3 optional: true + node-addon-api@7.1.1: {} + node-domexception@1.0.0: {} node-emoji@2.2.0: @@ -14196,6 +15024,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.27: {} nodemon@3.1.11: @@ -15439,6 +16272,8 @@ snapshots: toidentifier@1.0.1: {} + toml@3.0.0: {} + totalist@3.0.1: {} touch@3.1.1: {} @@ -15637,6 +16472,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.24.4: {} + unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.3.0: {} diff --git a/turbo.json b/turbo.json index f85c2f0c..0dc29fd9 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turborepo.com/schema.json", + "concurrency": "11", "ui": "tui", "tasks": { "build": {