Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}
Expand All @@ -102,9 +113,11 @@ ${bold("USAGE")}
${bold("DEPLOY (operator)")} ${dim("— runs in the deployment directory")}
init [path] [--org <id>] [--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
Expand Down Expand Up @@ -237,17 +250,19 @@ async function dispatch(argv: string[]): Promise<void> {
}

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({
dir,
...(org ? { org } : {}),
...(target ? { target } : {}),
...(modelProvider ? { modelProvider } : {}),
...(emailTransport ? { emailTransport } : {}),
});
return;
}
Expand Down
12 changes: 10 additions & 2 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
configPathInDir,
loadConfigAt,
validOrgId,
type EmailTransport,
type ModelProvider,
type Target,
type QmConfig,
Expand Down Expand Up @@ -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)`);
Expand All @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export const MODEL_PROVIDER_HARNESSES: Readonly<Record<ModelProvider, readonly s
export const isModelProvider = (value: unknown): value is ModelProvider =>
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;
Expand Down Expand Up @@ -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 (
Expand Down
12 changes: 6 additions & 6 deletions cli/src/provider-scaffold.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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[];
Expand Down Expand Up @@ -170,7 +170,7 @@ export const dockerScaffold: ProviderScaffold = {
};

export const flyScaffold: ProviderScaffold = {
renderConfig: (orgId, modelProvider) =>
renderConfig: (orgId, modelProvider, emailTransport) =>
renderConfig(orgId, {
target: "fly",
modelProvider,
Expand All @@ -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.
Expand All @@ -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(
[
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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 = [
Expand Down
20 changes: 20 additions & 0 deletions cli/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
Loading