diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 37c20c81a..121a3f276 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -3,11 +3,14 @@ import { dirname, join, resolve } from "node:path"; import { CliError, bold, dim, errMessage, note, red } from "./log.ts"; import { findConfigPath, + isEmailTransport, isModelProvider, loadConfigAt, loadConfigInDir, readConfigOrgId, + EMAIL_TRANSPORTS, MODEL_PROVIDERS, + type EmailTransport, type ModelProvider, } from "./config.ts"; import { HOSTING_PROVIDER_IDS, hostingProviderChoices, isTarget, type Target } from "./providers.ts"; @@ -87,6 +90,14 @@ function modelProviderFlag(flags: Flags): ModelProvider | undefined { return provider; } +function emailTransportFlag(flags: Flags): EmailTransport | undefined { + const transport = strFlag(flags, "email-transport"); + if (transport !== undefined && !isEmailTransport(transport)) { + throw new CliError(`--email-transport must be ${EMAIL_TRANSPORTS.join(" | ")}`, { clause: "cli.invocation" }); + } + return transport; +} + function version(): string { return cliVersion(); } @@ -102,9 +113,11 @@ ${bold("USAGE")} ${bold("DEPLOY (operator)")} ${dim("— runs in the deployment directory")} init [path] [--org ] [--target ${HOSTING_PROVIDER_IDS.join("|")}] [--model-provider ${MODEL_PROVIDERS.join("|")}] + [--email-transport ${EMAIL_TRANSPORTS.join("|")}] scaffold a deployment directory (the base model provider defaults to anthropic; its API key is a - required secret) + required secret; sign-in emails default to resend, + and only the chosen transport's keys are scaffolded) setup [path] interactive wizard: scaffold if needed, then walk the missing secrets with per-provider instructions up build images and bring the deployment up @@ -237,10 +250,11 @@ async function dispatch(argv: string[]): Promise { } case "init": { - rejectUnknownFlags(flags, ["org", "target", "model-provider"]); + rejectUnknownFlags(flags, ["org", "target", "model-provider", "email-transport"]); rejectExtraPositionals(positionals, 1); const target = targetFlag(flags); const modelProvider = modelProviderFlag(flags); + const emailTransport = emailTransportFlag(flags); const org = strFlag(flags, "org"); const dir = positionals[0] !== undefined ? resolve(positionals[0]) : resolve(process.cwd()); runInit({ @@ -248,6 +262,7 @@ async function dispatch(argv: string[]): Promise { ...(org ? { org } : {}), ...(target ? { target } : {}), ...(modelProvider ? { modelProvider } : {}), + ...(emailTransport ? { emailTransport } : {}), }); return; } diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index f1cbddfd1..4572fa8f4 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -7,6 +7,7 @@ import { configPathInDir, loadConfigAt, validOrgId, + type EmailTransport, type ModelProvider, type Target, type QmConfig, @@ -256,7 +257,13 @@ function scaffoldDeploymentSkill(dir: string): void { } } -export function runInit(opts: { org?: string; target?: Target; modelProvider?: ModelProvider; dir?: string }): void { +export function runInit(opts: { + org?: string; + target?: Target; + modelProvider?: ModelProvider; + emailTransport?: EmailTransport; + dir?: string; +}): void { assertNodeEngine(); const orgId = opts.org ?? "default-org"; if (!validOrgId(orgId)) die(`--org must be a lowercase DNS label (a-z, 0-9, and hyphens between)`); @@ -273,8 +280,9 @@ export function runInit(opts: { org?: string; target?: Target; modelProvider?: M const target: Target = opts.target ?? "docker"; const modelProvider: ModelProvider = opts.modelProvider ?? "anthropic"; + const emailTransport: EmailTransport = opts.emailTransport ?? "resend"; const provider = hostingProvider(target); - writeFileSync(configPath, provider.scaffold.renderConfig(orgId, modelProvider)); + writeFileSync(configPath, provider.scaffold.renderConfig(orgId, modelProvider, emailTransport)); ok(`wrote ${CONFIG_FILENAME} (orgId=${orgId}, target=${target}, modelProvider=${modelProvider})`); const config = loadConfigAt(configPath).config; diff --git a/cli/src/config.ts b/cli/src/config.ts index 4d1f283ea..2277e65cf 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -122,6 +122,12 @@ export const MODEL_PROVIDER_HARNESSES: Readonly typeof value === "string" && (MODEL_PROVIDERS as readonly string[]).includes(value); +export const EMAIL_TRANSPORTS = ["resend", "smtp"] as const; +export type EmailTransport = (typeof EMAIL_TRANSPORTS)[number]; + +export const isEmailTransport = (value: unknown): value is EmailTransport => + typeof value === "string" && (EMAIL_TRANSPORTS as readonly string[]).includes(value); + export interface QmConfig { contract: typeof CONTRACT_VERSION; orgId: string; @@ -761,8 +767,10 @@ function validateBrokerTrust(config: QmConfig, path: string, secrets?: ReadonlyM ); } const transport = authEnv.AUTH_EMAIL_TRANSPORT?.trim(); - if (transport !== "resend" && transport !== "smtp") { - throw new CliError(`${path}: env.auth.AUTH_EMAIL_TRANSPORT must be "resend" or "smtp"`); + if (!isEmailTransport(transport)) { + throw new CliError( + `${path}: env.auth.AUTH_EMAIL_TRANSPORT must be ${EMAIL_TRANSPORTS.map((t) => JSON.stringify(t)).join(" or ")}`, + ); } const domain = authEnv.AUTH_ALLOWED_EMAIL_DOMAIN?.trim(); if ( diff --git a/cli/src/provider-scaffold.ts b/cli/src/provider-scaffold.ts index ab8e23252..408995896 100644 --- a/cli/src/provider-scaffold.ts +++ b/cli/src/provider-scaffold.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import type { ModelProvider, QmConfig } from "./config.ts"; +import type { EmailTransport, ModelProvider, QmConfig } from "./config.ts"; import type { Target } from "./providers.ts"; import { declaredVariables, terraformVars } from "./terraform.ts"; @@ -10,7 +10,7 @@ interface ScaffoldFile { } export interface ProviderScaffold { - renderConfig(orgId: string, modelProvider: ModelProvider): string; + renderConfig(orgId: string, modelProvider: ModelProvider, emailTransport: EmailTransport): string; ignores: readonly string[]; agentsAppendix: string; files(config: QmConfig): ScaffoldFile[]; @@ -170,7 +170,7 @@ export const dockerScaffold: ProviderScaffold = { }; export const flyScaffold: ProviderScaffold = { - renderConfig: (orgId, modelProvider) => + renderConfig: (orgId, modelProvider, emailTransport) => renderConfig(orgId, { target: "fly", modelProvider, @@ -184,7 +184,7 @@ export const flyScaffold: ProviderScaffold = { "flyOrg": "personal", `, services: ["core", "slack", "web-ui", "admin", "portal", "auth"], - env: `{ "core": { "HARNESS": "pi", "SNAPSHOT_STORE": "s3", "TRANSFER_STORE": "s3", "S3_BUCKET": ${JSON.stringify(`${orgId}-data`)}, "S3_REGION": "auto" }, "slack": { "SLACK_IDENTITY_EMAIL": "1" }, "auth": { "AUTH_EMAIL_TRANSPORT": "resend" } }`, + env: `{ "core": { "HARNESS": "pi", "SNAPSHOT_STORE": "s3", "TRANSFER_STORE": "s3", "S3_BUCKET": ${JSON.stringify(`${orgId}-data`)}, "S3_REGION": "auto" }, "slack": { "SLACK_IDENTITY_EMAIL": "1" }, "auth": { "AUTH_EMAIL_TRANSPORT": ${JSON.stringify(emailTransport)} } }`, secretEnv: `, // The initial admin seed is kept in the provider secret store, never in config. @@ -204,7 +204,7 @@ export const flyScaffold: ProviderScaffold = { }; export const awsScaffold: ProviderScaffold = { - renderConfig: (orgId, modelProvider) => { + renderConfig: (orgId, modelProvider, emailTransport) => { const cluster = clusterName(orgId); const services = Object.fromEntries( [ @@ -242,7 +242,7 @@ export const awsScaffold: ProviderScaffold = { }, `, services: ["core", "slack", "web-ui", "admin", "portal", "auth"], - env: `{ "core": { "HARNESS": "pi", "AWS_DEPLOY_IMAGE": ${JSON.stringify(`${cluster}-sandbox`)}, "AWS_PUBLIC_ORIGIN_URL": "http://replace-with-alb-hostname" }, "slack": { "SLACK_IDENTITY_EMAIL": "1" }, "auth": { "AUTH_EMAIL_TRANSPORT": "resend" } }`, + env: `{ "core": { "HARNESS": "pi", "AWS_DEPLOY_IMAGE": ${JSON.stringify(`${cluster}-sandbox`)}, "AWS_PUBLIC_ORIGIN_URL": "http://replace-with-alb-hostname" }, "slack": { "SLACK_IDENTITY_EMAIL": "1" }, "auth": { "AUTH_EMAIL_TRANSPORT": ${JSON.stringify(emailTransport)} } }`, secretEnv: `, // The initial admin seed is kept in the provider secret store, never in config. diff --git a/cli/src/secrets.ts b/cli/src/secrets.ts index 5de7571a9..ed061840b 100644 --- a/cli/src/secrets.ts +++ b/cli/src/secrets.ts @@ -544,6 +544,17 @@ export function secretsForService( return computedSecrets(config).filter((secret) => secretDestinations(secret, pluginNames).has(service)); } +function requiresOtherEmailTransport(config: QmConfig, condition: SecretCondition): boolean { + if (condition.kind === "all") + return condition.conditions.some((nested) => requiresOtherEmailTransport(config, nested)); + if (condition.kind === "any") + return condition.conditions.every((nested) => requiresOtherEmailTransport(config, nested)); + if (condition.kind !== "env-equals" || condition.service !== "auth" || condition.name !== "AUTH_EMAIL_TRANSPORT") + return false; + const configured = config.env.auth?.AUTH_EMAIL_TRANSPORT?.trim(); + return configured !== undefined && configured !== "" && configured !== condition.value; +} + function conditionClause(condition: SecretCondition): string { if (condition.kind === "service-enabled") return `the ${condition.service} service is enabled`; if (condition.kind === "service-absent") return `the ${condition.service} service is not enabled`; @@ -585,7 +596,10 @@ export function renderEnvExample(config: QmConfig): string { } const activeNames = new Set(active.map((secret) => secret.name)); const inactive = FIRST_PARTY_SECRET_SPECS.filter( - (spec, i, all) => !activeNames.has(spec.name) && all.findIndex((other) => other.name === spec.name) === i, + (spec, i, all) => + !activeNames.has(spec.name) && + all.findIndex((other) => other.name === spec.name) === i && + !(typeof spec.required === "object" && requiresOtherEmailTransport(config, spec.required.when)), ); for (const spec of inactive) { const clauses = [ diff --git a/cli/test/init.test.ts b/cli/test/init.test.ts index 7d2cbded4..1c334e40a 100644 --- a/cli/test/init.test.ts +++ b/cli/test/init.test.ts @@ -179,6 +179,26 @@ test("init --target fly scaffolds the full hosted topology and both Slack apps", for (const line of ["# OIDC_CLIENT_ID=", "# OIDC_CLIENT_SECRET=", "# PORTAL_EXPECTED_TEAM_ID="]) { assert.ok(env.split("\n").includes(line), `external-IdP secret ${line} stays documented but unrequired`); } + assert.ok(!env.includes("SMTP_"), "the unselected smtp transport's keys stay out of .env.example"); + assert.ok(!readFileSync(join(dir, ".env"), "utf8").includes("SMTP_"), "and out of .env"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("init --email-transport smtp scaffolds smtp keys only and a matching config", () => { + const dir = mkdtempSync(join(tmpdir(), "qm-init-smtp-")); + try { + quiet(() => runInit({ dir, org: "acme", target: "fly", emailTransport: "smtp" })); + const { config } = loadConfigInDir(dir); + assert.equal(config.env.auth?.AUTH_EMAIL_TRANSPORT, "smtp"); + const env = readFileSync(join(dir, ".env.example"), "utf8"); + assert.equal(env, renderEnvExample(config)); + for (const line of ["SMTP_HOST=", "SMTP_USERNAME=", "SMTP_PASSWORD="]) { + assert.ok(env.split("\n").includes(line), `.env.example should require ${line}`); + } + assert.ok(!env.includes("RESEND_API_KEY"), "the unselected resend transport's key stays out of .env.example"); + assert.ok(!readFileSync(join(dir, ".env"), "utf8").includes("RESEND_API_KEY"), "and out of .env"); } finally { rmSync(dir, { recursive: true, force: true }); }