improved module manager's secrets extraction, updated plugins

This commit is contained in:
dswbx
2025-09-05 13:31:20 +02:00
parent 94e168589d
commit bdcc81b2f1
10 changed files with 154 additions and 47 deletions

View File

@@ -50,16 +50,22 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
// @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 || {};
},

View File

@@ -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)) {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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>", "format output").choices(["json", "env"]).default("json"),
)
.option("--out <file>", "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("");
},
);
};

View File

@@ -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<void> {
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;
}

View File

@@ -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);

View File

@@ -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<void>;
};
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));
}

View File

@@ -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<string, any>) => Promise<void>;
};
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);
}

View File

@@ -2,10 +2,15 @@ import { App, type AppPlugin, EntityTypescript } from "bknd";
export type SyncTypesOptions = {
enabled?: boolean;
includeFirstBoot?: boolean;
write: (et: EntityTypescript) => Promise<void>;
};
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));
}