From bdcc81b2f12a591e0f193fd2f5a02eabd7b5ee9b Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 5 Sep 2025 13:31:20 +0200 Subject: [PATCH] improved module manager's secrets extraction, updated plugins --- app/src/adapter/cloudflare/proxy.ts | 12 +++-- app/src/adapter/cloudflare/vite.ts | 9 +++- app/src/cli/commands/config.ts | 10 +++- app/src/cli/commands/index.ts | 1 + app/src/cli/commands/secrets.ts | 58 ++++++++++++++++++++++ app/src/modules/ModuleManager.ts | 52 ++++++++++++++++++- app/src/modules/db/DbModuleManager.ts | 30 +---------- app/src/plugins/dev/sync-config.plugin.ts | 4 +- app/src/plugins/dev/sync-secrets.plugin.ts | 16 +++--- app/src/plugins/dev/sync-types.plugin.ts | 9 +++- 10 files changed, 154 insertions(+), 47 deletions(-) create mode 100644 app/src/cli/commands/secrets.ts diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts index ddbd4b3..4dcd0c7 100644 --- a/app/src/adapter/cloudflare/proxy.ts +++ b/app/src/adapter/cloudflare/proxy.ts @@ -50,16 +50,22 @@ export function withPlatformProxy( // @ts-ignore app: async (_env) => { const env = await getEnv(_env); + const binding = use_proxy ? getBinding(env, "D1Database") : undefined; - if (config?.app === undefined && use_proxy) { - const binding = getBinding(env, "D1Database"); + if (config?.app === undefined && use_proxy && binding) { return { connection: d1Sqlite({ binding: binding.value, }), }; } else if (typeof config?.app === "function") { - return config?.app(env); + const appConfig = await config?.app(env); + if (binding) { + appConfig.connection = d1Sqlite({ + binding: binding.value, + }) as any; + } + return appConfig; } return config?.app || {}; }, diff --git a/app/src/adapter/cloudflare/vite.ts b/app/src/adapter/cloudflare/vite.ts index 22862b1..064b823 100644 --- a/app/src/adapter/cloudflare/vite.ts +++ b/app/src/adapter/cloudflare/vite.ts @@ -24,7 +24,9 @@ export function devFsVitePlugin({ projectRoot = config.root; }, configureServer(server) { - if (!isDev) return; + if (!isDev) { + return; + } // Intercept stdout to watch for our write requests const originalStdoutWrite = process.stdout.write; @@ -78,7 +80,10 @@ export function devFsVitePlugin({ // @ts-ignore transform(code, id, options) { // Only transform in SSR mode during development - if (!isDev || !options?.ssr) return; + //if (!isDev || !options?.ssr) return; + if (!isDev) { + return; + } // Check if this is the bknd config file if (id.includes(configFile)) { diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 154453f..fca3586 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -4,6 +4,7 @@ import { makeAppFromEnv } from "cli/commands/run"; import { writeFile } from "node:fs/promises"; import c from "picocolors"; import { withConfigOptions } from "cli/utils/options"; +import { $console } from "bknd/utils"; export const config: CliCommand = (program) => { withConfigOptions(program.command("config")) @@ -19,7 +20,14 @@ export const config: CliCommand = (program) => { config = getDefaultConfig(); } else { const app = await makeAppFromEnv(options); - config = app.toJSON(options.secrets); + const manager = app.modules; + + if (options.secrets) { + $console.warn("Including secrets in output"); + config = manager.toJSON(true); + } else { + config = manager.extractSecrets().configs; + } } config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config); diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index ad014fb..87b6fcf 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -8,3 +8,4 @@ export { copyAssets } from "./copy-assets"; export { types } from "./types"; export { mcp } from "./mcp/mcp"; export { sync } from "./sync"; +export { secrets } from "./secrets"; diff --git a/app/src/cli/commands/secrets.ts b/app/src/cli/commands/secrets.ts new file mode 100644 index 0000000..795447b --- /dev/null +++ b/app/src/cli/commands/secrets.ts @@ -0,0 +1,58 @@ +import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; +import { transformObject } from "bknd/utils"; +import { Option } from "commander"; + +export const secrets: CliCommand = (program) => { + withConfigOptions(program.command("secrets")) + .description("get app secrets") + .option("--template", "template output without the actual secrets") + .addOption( + new Option("--format ", "format output").choices(["json", "env"]).default("json"), + ) + .option("--out ", "output file") + .action( + async ( + options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>, + ) => { + const app = await makeAppFromEnv(options); + const manager = app.modules; + + let secrets = manager.extractSecrets().secrets; + if (options.template) { + secrets = transformObject(secrets, () => ""); + } + + console.info(""); + if (options.out) { + if (options.format === "env") { + await writeFile( + options.out, + Object.entries(secrets) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ); + } else { + await writeFile(options.out, JSON.stringify(secrets, null, 2)); + } + console.info(`Secrets written to ${c.cyan(options.out)}`); + } else { + if (options.format === "env") { + console.info( + c.cyan( + Object.entries(secrets) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ), + ); + } else { + console.info(secrets); + } + } + console.info(""); + }, + ); +}; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 7a49225..aeebf8c 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,4 +1,4 @@ -import { objectEach, transformObject, McpServer, type s } from "bknd/utils"; +import { objectEach, transformObject, McpServer, type s, SecretSchema, setPath } from "bknd/utils"; import { DebugLogger } from "core/utils/DebugLogger"; import { Guard } from "auth/authorize/Guard"; import { env } from "core/env"; @@ -15,6 +15,7 @@ import { AppData } from "data/AppData"; import { AppFlows } from "flows/AppFlows"; import { AppMedia } from "media/AppMedia"; import type { PartialRec } from "core/types"; +import { mergeWith, pick } from "lodash-es"; export type { ModuleBuildContext }; @@ -207,9 +208,43 @@ export class ModuleManager { }; } + extractSecrets() { + const moduleConfigs = structuredClone(this.configs()); + const secrets = this.options?.secrets || ({} as any); + const extractedKeys: string[] = []; + + for (const [key, module] of Object.entries(this.modules)) { + const config = moduleConfigs[key]; + const schema = module.getSchema(); + + const extracted = [...schema.walk({ data: config })].filter( + (n) => n.schema instanceof SecretSchema, + ); + + //console.log("extracted", key, extracted, config); + for (const n of extracted) { + const path = [key, ...n.instancePath].join("."); + + if (typeof n.data === "string" && n.data.length > 0) { + extractedKeys.push(path); + secrets[path] = n.data; + setPath(moduleConfigs, path, ""); + } + } + } + + return { + configs: moduleConfigs, + secrets: pick(secrets, extractedKeys), + extractedKeys, + }; + } + protected async setConfigs(configs: ModuleConfigs): Promise { this.logger.log("setting configs"); for await (const [key, config] of Object.entries(configs)) { + if (!(key in this.modules)) continue; + try { // setting "noEmit" to true, to not force listeners to update const result = await this.modules[key].schema().set(config as any, true); @@ -226,6 +261,21 @@ export class ModuleManager { this.createModules(this.options?.initial ?? {}); await this.buildModules(); + // if secrets were provided, extract, merge and build again + const provided_secrets = this.options?.secrets ?? {}; + if (Object.keys(provided_secrets).length > 0) { + const { configs, secrets, extractedKeys } = this.extractSecrets(); + + for (const key of extractedKeys) { + if (key in provided_secrets) { + setPath(configs, key, secrets[key]); + } + } + + await this.setConfigs(configs); + await this.buildModules(); + } + return this; } diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index bf69355..470f517 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -165,34 +165,6 @@ export class DbModuleManager extends ModuleManager { return { configs, secrets }; } - extractSecrets() { - const moduleConfigs = structuredClone(this.configs()); - const secrets = this.options?.secrets || ({} as any); - - for (const [key, module] of Object.entries(this.modules)) { - const config = moduleConfigs[key]; - const schema = module.getSchema(); - - const extracted = [...schema.walk({ data: config })].filter( - (n) => n.schema instanceof SecretSchema, - ); - - //console.log("extracted", key, extracted, config); - for (const n of extracted) { - const path = [key, ...n.instancePath].join("."); - if (typeof n.data === "string" && n.data.length > 0) { - secrets[path] = n.data; - setPath(moduleConfigs, path, ""); - } - } - } - - return { - configs: moduleConfigs, - secrets, - }; - } - async save() { this.logger.context("save").log("saving version", this.version()); const { configs, secrets } = this.extractSecrets(); @@ -234,7 +206,7 @@ export class DbModuleManager extends ModuleManager { updates.push({ version: state.configs.version, type: "secrets", - json: secrets, + json: secrets as any, }); } await this.mutator().insertMany(updates); diff --git a/app/src/plugins/dev/sync-config.plugin.ts b/app/src/plugins/dev/sync-config.plugin.ts index 535e538..907e1bf 100644 --- a/app/src/plugins/dev/sync-config.plugin.ts +++ b/app/src/plugins/dev/sync-config.plugin.ts @@ -3,12 +3,14 @@ import { App, type AppConfig, type AppPlugin } from "bknd"; export type SyncConfigOptions = { enabled?: boolean; includeSecrets?: boolean; + includeFirstBoot?: boolean; write: (config: AppConfig) => Promise; }; export function syncConfig({ enabled = true, includeSecrets = false, + includeFirstBoot = false, write, }: SyncConfigOptions): AppPlugin { let firstBoot = true; @@ -26,7 +28,7 @@ export function syncConfig({ }, ); - if (firstBoot) { + if (firstBoot && includeFirstBoot) { firstBoot = false; await write?.(app.toJSON(includeSecrets)); } diff --git a/app/src/plugins/dev/sync-secrets.plugin.ts b/app/src/plugins/dev/sync-secrets.plugin.ts index 16e0357..755c4b7 100644 --- a/app/src/plugins/dev/sync-secrets.plugin.ts +++ b/app/src/plugins/dev/sync-secrets.plugin.ts @@ -1,22 +1,22 @@ import { type App, ModuleManagerEvents, type AppPlugin } from "bknd"; -import type { DbModuleManager } from "modules/db/DbModuleManager"; export type SyncSecretsOptions = { enabled?: boolean; + includeFirstBoot?: boolean; write: (secrets: Record) => Promise; }; -export function syncSecrets({ enabled = true, write }: SyncSecretsOptions): AppPlugin { +export function syncSecrets({ + enabled = true, + includeFirstBoot = false, + write, +}: SyncSecretsOptions): AppPlugin { let firstBoot = true; return (app: App) => ({ name: "bknd-sync-secrets", onBuilt: async () => { if (!enabled) return; - const manager = app.modules as DbModuleManager; - - if (!("extractSecrets" in manager)) { - throw new Error("ModuleManager does not support secrets"); - } + const manager = app.modules; app.emgr.onEvent( ModuleManagerEvents.ModuleManagerSecretsExtractedEvent, @@ -28,7 +28,7 @@ export function syncSecrets({ enabled = true, write }: SyncSecretsOptions): AppP }, ); - if (firstBoot) { + if (firstBoot && includeFirstBoot) { firstBoot = false; await write?.(manager.extractSecrets().secrets); } diff --git a/app/src/plugins/dev/sync-types.plugin.ts b/app/src/plugins/dev/sync-types.plugin.ts index c632e63..11e8c13 100644 --- a/app/src/plugins/dev/sync-types.plugin.ts +++ b/app/src/plugins/dev/sync-types.plugin.ts @@ -2,10 +2,15 @@ import { App, type AppPlugin, EntityTypescript } from "bknd"; export type SyncTypesOptions = { enabled?: boolean; + includeFirstBoot?: boolean; write: (et: EntityTypescript) => Promise; }; -export function syncTypes({ enabled = true, write }: SyncTypesOptions): AppPlugin { +export function syncTypes({ + enabled = true, + includeFirstBoot = false, + write, +}: SyncTypesOptions): AppPlugin { let firstBoot = true; return (app: App) => ({ name: "bknd-sync-types", @@ -21,7 +26,7 @@ export function syncTypes({ enabled = true, write }: SyncTypesOptions): AppPlugi }, ); - if (firstBoot) { + if (firstBoot && includeFirstBoot) { firstBoot = false; await write?.(new EntityTypescript(app.em)); }