From 76096a57c9e798d53e9e1fd80eba9153f2e3a481 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Thu, 11 Jun 2026 19:03:23 +0100 Subject: [PATCH] latest --- apps/leaf/scripts/seedSlackInstall.ts | 20 +- scripts/dev.ts | 12 +- scripts/dw/helpers/setup.ts | 62 +- scripts/slack/index.ts | 542 ++++++++++++++++-- .../agent/knowledge-platform-scenario.test.ts | 42 ++ .../scenarios/agent/knowledge-platform.ts | 478 +++++++++++++++ .../tests/scenarios/seedUtils/concurrency.ts | 25 + .../scenarios/seedUtils/customerSeedTypes.ts | 23 + server/tests/scenarios/seedUtils/index.ts | 4 + .../scenarios/seedUtils/realisticData.ts | 185 ++++++ .../scenarios/seedUtils/seedCustomers.ts | 93 +++ 11 files changed, 1409 insertions(+), 77 deletions(-) create mode 100644 server/tests/scenarios/agent/knowledge-platform-scenario.test.ts create mode 100644 server/tests/scenarios/agent/knowledge-platform.ts create mode 100644 server/tests/scenarios/seedUtils/concurrency.ts create mode 100644 server/tests/scenarios/seedUtils/customerSeedTypes.ts create mode 100644 server/tests/scenarios/seedUtils/index.ts create mode 100644 server/tests/scenarios/seedUtils/realisticData.ts create mode 100644 server/tests/scenarios/seedUtils/seedCustomers.ts diff --git a/apps/leaf/scripts/seedSlackInstall.ts b/apps/leaf/scripts/seedSlackInstall.ts index cb07242e3..2c3c4fe98 100644 --- a/apps/leaf/scripts/seedSlackInstall.ts +++ b/apps/leaf/scripts/seedSlackInstall.ts @@ -7,10 +7,16 @@ // ENV_FILE=.env infisical run --env=dev --recursive -- \ // bun apps/leaf/scripts/seedSlackInstall.ts (DATABASE_URL=) // -// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token, xoxb-…) in the env — -// skips cleanly if absent. +// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token, xoxb-…) in the env. +// SLACK_CLIENT_ID / SLACK_CLIENT_SECRET configure OAuth, but cannot mint a bot +// token without an install callback code, so this skips cleanly if absent. import crypto from "node:crypto"; -import { AppEnv, type ChatInstallState, member, organizations } from "@autumn/shared"; +import { + AppEnv, + type ChatInstallState, + member, + organizations, +} from "@autumn/shared"; import { eq } from "drizzle-orm"; import { db } from "../src/lib/db.js"; import { replaceInstallation } from "../src/providers/slack/installations.js"; @@ -44,7 +50,9 @@ const log = (message: string) => console.log(`[seed-slack] ${message}`); const main = async () => { const botToken = process.env.SLACK_BOT_TOKEN; if (!botToken) { - log("skipping: SLACK_BOT_TOKEN not set"); + log( + "skipping: SLACK_BOT_TOKEN not set (client id/secret are not enough to seed an installed bot)", + ); return; } @@ -82,7 +90,9 @@ const main = async () => { orgId = byId?.id; } if (!orgId) { - log(`skipping: org '${SEED_ORG_SLUG}' not found (run the test-org seed first)`); + log( + `skipping: org '${SEED_ORG_SLUG}' not found (run the test-org seed first)`, + ); return; } diff --git a/scripts/dev.ts b/scripts/dev.ts index e6bac71f9..2036845be 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -39,11 +39,15 @@ const viteAppEnv = envFile.includes(".env.prod") const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode; const localUrl = (value: string | undefined, fallback: string) => value && !value.includes(".useautumn.com") ? value : fallback; +const slackRedirectFromPublicTunnel = publicTunnelUrl + ? `${publicTunnelUrl}/slack/oauth/callback` + : undefined; const SLACK_REDIRECT_URI = useLocalAuthUrls - ? localUrl( + ? (slackRedirectFromPublicTunnel ?? + localUrl( process.env.SLACK_REDIRECT_URI, `${SLACK_BOT_URL}/slack/oauth/callback`, - ) + )) : (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`); /** @@ -202,7 +206,9 @@ async function startDev() { // Use the locally-installed (pinned) trigger.dev CLI. Passing // `@` makes bunx fetch a fresh copy into a temp dir, // which can be broken/incomplete (ERR_MODULE_NOT_FOUND). - cmds.push(isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`); + cmds.push( + isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`, + ); } names.push("vite", "checkout"); diff --git a/scripts/dw/helpers/setup.ts b/scripts/dw/helpers/setup.ts index edbd62b16..143898422 100644 --- a/scripts/dw/helpers/setup.ts +++ b/scripts/dw/helpers/setup.ts @@ -1,14 +1,14 @@ -import { log, fatal, shInherit } from "./shell.ts"; +import { NEON_TEMPLATE_BRANCH, PROJECT_ROOT } from "../constants.ts"; +import type { RegistryEntry } from "../types.ts"; +import { applyCommittedMigrations, loadDbFunctions } from "./migration.ts"; import { - ensureTemplateBranch, - createBranch, connectionString, + createBranch, + ensureTemplateBranch, findBranchByName, } from "./neon.ts"; -import { applyCommittedMigrations, loadDbFunctions } from "./migration.ts"; -import { loadRegistry, saveRegistry } from "./registry.ts"; -import { PROJECT_ROOT, NEON_TEMPLATE_BRANCH } from "../constants.ts"; -import type { RegistryEntry } from "../types.ts"; +import { saveRegistry } from "./registry.ts"; +import { fatal, log, shInherit } from "./shell.ts"; export async function setupAgentWorktree( entry: RegistryEntry, @@ -61,18 +61,14 @@ export async function autoSetupTestOrg(entry: RegistryEntry): Promise { return; } log(`seeding unit test org in ${entry.branchName ?? "worktree"}`); - const code = shInherit( - "bun", - ["scripts/setup/setup-test.ts", "--yes"], - { - cwd: PROJECT_ROOT, - env: { - ...(process.env as Record), - DATABASE_URL: entry.databaseUrl, - DATABASE_CRITICAL_URL: entry.databaseUrl, - }, + const code = shInherit("bun", ["scripts/setup/setup-test.ts", "--yes"], { + cwd: PROJECT_ROOT, + env: { + ...(process.env as Record), + DATABASE_URL: entry.databaseUrl, + DATABASE_CRITICAL_URL: entry.databaseUrl, }, - ); + }); if (code !== 0) { console.error(`[dw] setup-test exited with code ${code}; continuing`); } @@ -80,30 +76,32 @@ export async function autoSetupTestOrg(entry: RegistryEntry): Promise { // Seed the Slack `chat_installations` row (+ OAuth creds) for the worktree's test // org so the dev Slack app works without a manual OAuth install per worktree. -// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token); skips otherwise. +// Needs SLACK_BOT_TOKEN (the app's Bot User OAuth Token). SLACK_CLIENT_ID / +// SLACK_CLIENT_SECRET configure OAuth, but cannot mint a bot token without an +// install callback code; skips otherwise. // Non-fatal, like autoSetupTestOrg. -export async function autoSeedSlackInstall(entry: RegistryEntry): Promise { +export async function autoSeedSlackInstall( + entry: RegistryEntry, +): Promise { if (!entry.databaseUrl) { log("autoSeedSlackInstall: no databaseUrl on entry, skipping"); return; } if (!process.env.SLACK_BOT_TOKEN) { - log("autoSeedSlackInstall: SLACK_BOT_TOKEN not set, skipping"); + log( + "autoSeedSlackInstall: SLACK_BOT_TOKEN not set, skipping (client id/secret are not enough to seed an installed bot)", + ); return; } log(`seeding slack installation in ${entry.branchName ?? "worktree"}`); - const code = shInherit( - "bun", - ["apps/leaf/scripts/seedSlackInstall.ts"], - { - cwd: PROJECT_ROOT, - env: { - ...(process.env as Record), - DATABASE_URL: entry.databaseUrl, - DATABASE_CRITICAL_URL: entry.databaseUrl, - }, + const code = shInherit("bun", ["apps/leaf/scripts/seedSlackInstall.ts"], { + cwd: PROJECT_ROOT, + env: { + ...(process.env as Record), + DATABASE_URL: entry.databaseUrl, + DATABASE_CRITICAL_URL: entry.databaseUrl, }, - ); + }); if (code !== 0) { console.error(`[dw] seedSlackInstall exited with code ${code}; continuing`); } diff --git a/scripts/slack/index.ts b/scripts/slack/index.ts index 4f6a6d6b3..345ef148f 100644 --- a/scripts/slack/index.ts +++ b/scripts/slack/index.ts @@ -1,6 +1,13 @@ import "dotenv/config"; -import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import chalk from "chalk"; import inquirer from "inquirer"; @@ -29,7 +36,11 @@ const readEnvVarFromFile = ({ // the dw registry. Prefer those over process.env.NGROK_URL, which Infisical // injects as a single shared dev tunnel — otherwise every worktree's Slack app // would be pointed at the same URL instead of its own. -const resolveWorktreeNgrokUrl = (): string | undefined => { +const resolveWorktreeNgrokUrl = ({ + includeEnvFallback = true, +}: { + includeEnvFallback?: boolean; +} = {}): string | undefined => { const fromEnvFile = readEnvVarFromFile({ filePath: join(repoRoot, "server", ".env.local"), key: "NGROK_URL", @@ -39,10 +50,9 @@ const resolveWorktreeNgrokUrl = (): string | undefined => { try { const registryPath = join(homedir(), ".autumn-worktrees.json"); if (existsSync(registryPath)) { - const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as Record< - string, - { ngrokUrl?: string } - >; + const registry = JSON.parse( + readFileSync(registryPath, "utf-8"), + ) as Record; const entry = registry[repoRoot]; if (entry?.ngrokUrl) return entry.ngrokUrl; } @@ -50,7 +60,7 @@ const resolveWorktreeNgrokUrl = (): string | undefined => { // Malformed/absent registry — fall through to the shared env value. } - return process.env.NGROK_URL; + return includeEnvFallback ? process.env.NGROK_URL : undefined; }; const defaultSlackScopes = [ @@ -88,15 +98,18 @@ type Args = { dryRun: boolean; envFile?: string; help: boolean; + yes: boolean; printManifest: boolean; provider?: SlackInstallProvider; scopes: string[]; target?: SlackManifestTarget; teamId?: string; + writeInfisicalEnv?: SlackInfisicalWriteEnv; }; type SlackInstallProvider = "slack" | "slack_admin"; type SlackManifestTarget = "local" | "prod" | "admin" | "all"; +type SlackInfisicalWriteEnv = "dev" | "prod"; type SlackManifest = { display_information: { @@ -153,6 +166,10 @@ type SlackManifestUpdateResponse = SlackApiResponse & { app_id?: string; }; +type SlackManifestExportResponse = SlackApiResponse & { + manifest?: SlackManifest; +}; + type SlackApiResponse = { ok: boolean; error?: string; @@ -167,32 +184,46 @@ const usage = () => " bun slack [options]", "", chalk.bold("Commands:"), - ` ${chalk.cyan("worktree")} Repoint the local Slack app's chat (/slack/events),`, + ` ${chalk.cyan("worktree")} Repoint known local Slack app chat (/slack/events),`, " approval (/slack/interactions) and OAuth URLs at THIS", " worktree's ngrok tunnel. Base URL is auto-read from", " server/.env.local / the dw registry — no --base-url needed.", ` ${chalk.cyan("setup-bot")} Interactively create a NEW Slack app (manifest) for`, " local dev and print its credentials. Prompts for a", " regular org bot or an admin impersonation bot.", + ` ${chalk.cyan("setup-local-bot")} Create a NEW regular local Slack app and write only`, + " the local Slack bot env vars as Infisical dev", + " personal overrides.", + ` ${chalk.cyan("setup-local-admin-bot")} Create a NEW local admin Slack app and, after`, + " confirmation, overwrite only the Slack bot env vars", + " as Infisical prod personal overrides.", ` ${chalk.cyan("update-manifest")} Update an EXISTING Slack app's manifest. Choose the`, " app(s) with --target .", "", chalk.bold("Options:"), " --app-id Existing Slack app id for manifest updates.", - " --base-url Public Leaf URL. Defaults to this worktree's NGROK_URL,", - " then SLACK_BOT_URL / CHAT_URL.", + " --base-url Public Leaf URL. Local setup defaults to this", + " worktree's NGROK_URL only; manifest updates can", + " also fall back to SLACK_BOT_URL / CHAT_URL.", " --name Slack app name. Defaults to Autumn Chat Local.", " --env-file Write Slack env vars to this file.", " --provider slack or slack_admin (setup-bot only).", " --scopes Override bot scopes.", " --target update-manifest target: local, prod, admin, or all.", " --team-id Workspace team id for org-scoped Slack CLI auth.", + " --no-infisical Do not write setup-local-bot vars to Infisical.", + " --infisical-dev Write local Slack bot vars as Infisical dev personal overrides.", + " --infisical-prod Write local Slack bot vars as Infisical prod personal overrides.", + " --yes Skip confirmation prompts for Infisical writes.", + " Ignored for setup-local-admin-bot prod writes.", " --print-manifest Print generated Slack app manifest.", " --dry-run Print manifest/env without calling Slack.", " --help Show this help.", "", chalk.bold("Examples:"), " bun slack worktree", + " bun slack setup-local-bot", + " bun slack setup-local-admin-bot", " bun slack setup-bot --provider slack_admin", " bun slack update-manifest --target all --base-url https://j.dev.useautumn.com", ].join("\n"); @@ -219,10 +250,20 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => { const scopes = readOption({ args: argv, name: "--scopes" }); const providerArg = readOption({ args: argv, name: "--provider" }); const targetArg = readOption({ args: argv, name: "--target" }); + const isLocalSetupAction = + action === "setup-local-bot" || action === "setup-local-admin-bot"; + const baseUrlOption = readOption({ args: argv, name: "--base-url" }); + const writeInfisicalEnv = argv.includes("--no-infisical") + ? undefined + : argv.includes("--infisical-prod") || action === "setup-local-admin-bot" + ? "prod" + : argv.includes("--infisical-dev") || action === "setup-local-bot" + ? "dev" + : undefined; const provider = providerArg === "slack" || providerArg === "slack_admin" ? providerArg - : action === "setup-admin-bot" + : action === "setup-admin-bot" || action === "setup-local-admin-bot" ? "slack_admin" : action === "setup-local-bot" || action === "setup-regular-bot" ? "slack" @@ -237,13 +278,17 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => { appId: readOption({ args: argv, name: "--app-id" }), appName: readOption({ args: argv, name: "--name" }) ?? defaultAppName, baseUrl: - readOption({ args: argv, name: "--base-url" }) ?? - resolveWorktreeNgrokUrl() ?? - process.env.SLACK_BOT_URL ?? - process.env.CHAT_URL, + baseUrlOption ?? + resolveWorktreeNgrokUrl({ + includeEnvFallback: !isLocalSetupAction, + }) ?? + (isLocalSetupAction + ? undefined + : (process.env.SLACK_BOT_URL ?? process.env.CHAT_URL)), dryRun: argv.includes("--dry-run"), envFile: readOption({ args: argv, name: "--env-file" }), help: argv.includes("--help") || argv.includes("-h"), + yes: argv.includes("--yes") || argv.includes("-y"), printManifest: argv.includes("--print-manifest"), provider, scopes: scopes @@ -265,6 +310,7 @@ const parseArgs = ({ argv }: { argv: string[] }): Args => { ? "all" : undefined, teamId: readOption({ args: argv, name: "--team-id" }), + writeInfisicalEnv, }; }; @@ -291,6 +337,9 @@ const resolveInteractiveArgs = async ({ }: { args: Args; }): Promise => { + const isLocalSetupAction = + args.action === "setup-local-bot" || + args.action === "setup-local-admin-bot"; const answers = await inquirer.prompt<{ provider?: SlackInstallProvider; appName?: string; @@ -317,16 +366,19 @@ const resolveInteractiveArgs = async ({ }, ] : []), - { - type: "input", - name: "appName", - message: "Slack app name", - default: ({ provider }: { provider?: SlackInstallProvider }) => - args.appName ?? - defaultAppNameForProvider({ - provider: provider ?? args.provider ?? "slack", - }), - }, + ...(!args.appName + ? [ + { + type: "input" as const, + name: "appName" as const, + message: "Slack app name", + default: ({ provider }: { provider?: SlackInstallProvider }) => + defaultAppNameForProvider({ + provider: provider ?? args.provider ?? "slack", + }), + }, + ] + : []), ...(!args.baseUrl ? [ { @@ -334,16 +386,19 @@ const resolveInteractiveArgs = async ({ name: "baseUrl" as const, message: "Public ngrok/Leaf URL", default: - resolveWorktreeNgrokUrl() ?? - process.env.SLACK_BOT_URL ?? - process.env.CHAT_URL, + resolveWorktreeNgrokUrl({ + includeEnvFallback: !isLocalSetupAction, + }) ?? + (isLocalSetupAction + ? undefined + : (process.env.SLACK_BOT_URL ?? process.env.CHAT_URL)), filter: (value: string) => trimTrailingSlash({ url: value.trim() }), validate: (value: string) => isUrl({ value }) || "Enter a valid http(s) URL", }, ] : []), - ...(!args.envFile + ...(!args.envFile && !args.writeInfisicalEnv ? [ { type: "input" as const, @@ -479,6 +534,80 @@ const parseSlackJson = ({ // Config tokens come from https://api.slack.com/apps → "Your App Configuration // Tokens". Generate once, set SLACK_CONFIG_REFRESH_TOKEN, and we self-rotate. const configTokenStorePath = join(homedir(), ".autumn-slack-config.json"); +const localSlackAppsStorePath = join(homedir(), ".autumn-slack-apps.json"); + +type StoredLocalSlackApp = { + appId: string; + appName: string; + clientId: string; + provider: SlackInstallProvider; + updatedAt: number; +}; + +const readStoredLocalSlackApps = (): StoredLocalSlackApp[] => { + if (!existsSync(localSlackAppsStorePath)) return []; + try { + const parsed = JSON.parse( + readFileSync(localSlackAppsStorePath, "utf-8"), + ) as { apps?: StoredLocalSlackApp[] }; + return Array.isArray(parsed.apps) ? parsed.apps : []; + } catch { + return []; + } +}; + +const writeStoredLocalSlackApps = ({ + apps, +}: { + apps: StoredLocalSlackApp[]; +}) => { + writeFileSync(localSlackAppsStorePath, JSON.stringify({ apps }, null, 2)); + try { + chmodSync(localSlackAppsStorePath, 0o600); + } catch { + // Best-effort file permissions; not fatal where chmod is unavailable. + } +}; + +const rememberLocalSlackApp = ({ + appId, + appName, + clientId, + provider, +}: { + appId: string; + appName: string; + clientId: string; + provider: SlackInstallProvider; +}) => { + const apps = readStoredLocalSlackApps(); + const withoutExisting = apps.filter( + (app) => app.appId !== appId && app.clientId !== clientId, + ); + writeStoredLocalSlackApps({ + apps: [ + ...withoutExisting, + { appId, appName, clientId, provider, updatedAt: Date.now() }, + ], + }); +}; + +const resolveStoredLocalSlackAppId = ({ + clientId, + provider, +}: { + clientId?: string; + provider: SlackInstallProvider; +}) => { + const apps = readStoredLocalSlackApps() + .filter((app) => app.provider === provider) + .sort((a, b) => b.updatedAt - a.updatedAt); + return ( + (clientId + ? apps.find((app) => app.clientId === clientId)?.appId + : undefined) ?? apps[0]?.appId + ); +}; const readStoredRefreshToken = (): string | undefined => { if (!existsSync(configTokenStorePath)) return undefined; @@ -497,7 +626,10 @@ const writeStoredRefreshToken = ({ }: { refreshToken: string; }) => { - writeFileSync(configTokenStorePath, JSON.stringify({ refreshToken }, null, 2)); + writeFileSync( + configTokenStorePath, + JSON.stringify({ refreshToken }, null, 2), + ); try { chmodSync(configTokenStorePath, 0o600); } catch { @@ -641,11 +773,263 @@ const updateSlackAppManifest = async ({ return json; }; +const exportSlackAppManifest = async ({ + appId, + configToken, + teamId, +}: { + appId: string; + configToken?: string; + teamId?: string; +}): Promise => { + const output = runSlackCli({ + args: [ + "api", + "apps.manifest.export", + ...(configToken ? ["--token", configToken] : []), + "--json", + JSON.stringify({ + app_id: appId, + ...(teamId ? { team_id: teamId } : {}), + }), + ], + quiet: true, + }); + const json = parseSlackJson({ + output, + label: "apps.manifest.export", + }); + if (!json.ok) + throw new Error(`Slack app manifest export failed: ${json.error}`); + return json.manifest; +}; + +const preserveSlackAppManifestNames = async ({ + appId, + configToken, + manifest, + teamId, +}: { + appId: string; + configToken?: string; + manifest: SlackManifest; + teamId?: string; +}): Promise => { + const currentManifest = await exportSlackAppManifest({ + appId, + configToken, + teamId, + }); + if (!currentManifest) return manifest; + + return { + ...manifest, + display_information: + currentManifest.display_information ?? manifest.display_information, + features: { + ...manifest.features, + bot_user: { + ...manifest.features.bot_user, + display_name: + currentManifest.features?.bot_user?.display_name ?? + manifest.features.bot_user.display_name, + }, + }, + }; +}; + const escapeEnvValue = ({ value }: { value: string }) => { if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; return JSON.stringify(value); }; +const localSlackInfisicalKeys = [ + "SLACK_APP_ID", + "SLACK_CLIENT_ID", + "SLACK_CLIENT_SECRET", + "SLACK_SIGNING_SECRET", +] as const; + +const slackInfisicalSecretPath = "/leaf"; +const slackInfisicalSecretType = "personal"; + +const localSlackInfisicalKeySet = new Set(localSlackInfisicalKeys); + +const assertSafeInfisicalSlackWrite = ({ + vars, +}: { + vars: Record; +}) => { + const keys = Object.keys(vars); + const unexpectedKeys = keys.filter( + (key) => !localSlackInfisicalKeySet.has(key), + ); + if (unexpectedKeys.length > 0) { + throw new Error( + [ + "Refusing to write unexpected Infisical Slack keys.", + `Allowed keys: ${localSlackInfisicalKeys.join(", ")}`, + `Unexpected keys: ${unexpectedKeys.join(", ")}`, + ].join("\n"), + ); + } +}; + +const redactValues = ({ + output, + values, +}: { + output: string; + values: string[]; +}) => { + let redacted = output; + for (const value of values) { + if (!value) continue; + redacted = redacted.replaceAll(value, ""); + } + return redacted; +}; + +const formatEnvFile = ({ vars }: { vars: Record }) => + Object.entries(vars) + .map(([key, value]) => `${key}=${escapeEnvValue({ value })}`) + .join("\n"); + +const verifyInfisicalSecretsNonEmpty = ({ + env, + keys, +}: { + env: SlackInfisicalWriteEnv; + keys: string[]; +}) => { + const missingKeys: string[] = []; + for (const key of keys) { + const result = Bun.spawnSync( + [ + "infisical", + "secrets", + "get", + key, + `--env=${env}`, + `--path=${slackInfisicalSecretPath}`, + "--plain", + "--silent", + ], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + const value = new TextDecoder().decode(result.stdout).trim(); + if (result.exitCode !== 0 || !value) { + missingKeys.push(key); + } + } + + if (missingKeys.length > 0) { + throw new Error( + [ + `Infisical ${env}${slackInfisicalSecretPath} write verification failed.`, + `Missing or empty keys: ${missingKeys.join(", ")}`, + ].join("\n"), + ); + } +}; + +const syncInfisicalLocalSlackSecrets = async ({ + env, + forceConfirmation, + provider, + vars, + yes, +}: { + env: SlackInfisicalWriteEnv; + forceConfirmation: boolean; + provider: SlackInstallProvider; + vars: Record; + yes: boolean; +}) => { + assertSafeInfisicalSlackWrite({ vars }); + if (env === "prod" && provider !== "slack_admin") { + throw new Error( + "Refusing to write Infisical prod Slack vars for a non-admin local bot", + ); + } + + const keys = Object.keys(vars); + if (forceConfirmation || !yes) { + const answer = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: "confirm", + name: "confirmed", + default: false, + message: [ + `Overwrite Infisical ${env.toUpperCase()} personal overrides for the ${provider === "slack_admin" ? "local admin" : "local"} Slack bot?`, + `Path: ${slackInfisicalSecretPath}`, + `Only these keys will be written: ${keys.join(", ")}`, + ].join("\n"), + }, + ]); + if (!answer.confirmed) { + throw new Error(`Cancelled Infisical ${env} secret overwrite`); + } + } + + const tempDir = mkdtempSync(join(tmpdir(), "autumn-slack-infisical-")); + const tempFile = join(tempDir, "slack.env"); + try { + writeFileSync(tempFile, `${formatEnvFile({ vars })}\n`); + try { + chmodSync(tempFile, 0o600); + } catch { + // Best-effort file permissions; the temp dir is still removed below. + } + + const result = Bun.spawnSync( + [ + "infisical", + "secrets", + "set", + `--env=${env}`, + `--path=${slackInfisicalSecretPath}`, + `--type=${slackInfisicalSecretType}`, + "--silent", + "--file", + tempFile, + ], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + if (result.exitCode !== 0) { + const values = Object.values(vars); + throw new Error( + [ + `infisical secrets set failed for ${env} keys: ${keys.join(", ")}`, + stdout ? redactValues({ output: stdout, values }) : undefined, + stderr ? redactValues({ output: stderr, values }) : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } + + verifyInfisicalSecretsNonEmpty({ env, keys }); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + + console.log( + chalk.green( + `Overwrote local Slack bot personal overrides in Infisical ${env}${slackInfisicalSecretPath}: ${keys.join(", ")}`, + ), + ); +}; + const upsertEnvFile = ({ filePath, vars, @@ -690,6 +1074,11 @@ const setupSlackBot = async ({ args }: { args: Args }) => { const provider = resolvedArgs.provider; const baseUrl = resolvedArgs.baseUrl; if (!baseUrl) throw new Error("Missing public Leaf URL"); + if (resolvedArgs.writeInfisicalEnv === "prod" && provider !== "slack_admin") { + throw new Error( + "Refusing to write Infisical prod Slack vars for a non-admin local bot", + ); + } const readyLabel = provider === "slack_admin" ? "Slack admin app ready" @@ -728,6 +1117,7 @@ const setupSlackBot = async ({ args }: { args: Args }) => { const clientSecret = credentials?.client_secret; const signingSecret = credentials?.signing_secret; const redirectUrl = manifest.oauth_config.redirect_urls[0]; + const appId = slackResponse?.app_id; if (!resolvedArgs.dryRun && (!clientId || !clientSecret || !signingSecret)) { console.log( @@ -736,13 +1126,21 @@ const setupSlackBot = async ({ args }: { args: Args }) => { console.log(JSON.stringify(slackResponse, null, 2)); throw new Error("Could not extract Slack app credentials from response"); } - const envVars = { + SLACK_APP_ID: appId ?? "", SLACK_CLIENT_ID: clientId ?? "", SLACK_CLIENT_SECRET: clientSecret ?? "", SLACK_SIGNING_SECRET: signingSecret ?? "", SLACK_REDIRECT_URI: redirectUrl, }; + if (!resolvedArgs.dryRun && appId && clientId) { + rememberLocalSlackApp({ + appId, + appName: manifest.display_information.name, + clientId, + provider, + }); + } console.log(chalk.green(`\n${readyLabel}`)); if (slackResponse?.app_id) console.log(`App ID: ${slackResponse.app_id}`); @@ -755,6 +1153,30 @@ const setupSlackBot = async ({ args }: { args: Args }) => { }); } + if (resolvedArgs.writeInfisicalEnv) { + const infisicalEnvVars = { + ...(provider === "slack" ? { SLACK_APP_ID: envVars.SLACK_APP_ID } : {}), + SLACK_CLIENT_ID: envVars.SLACK_CLIENT_ID, + SLACK_CLIENT_SECRET: envVars.SLACK_CLIENT_SECRET, + SLACK_SIGNING_SECRET: envVars.SLACK_SIGNING_SECRET, + }; + if (resolvedArgs.dryRun) { + console.log( + chalk.cyan( + `\nDry run: would overwrite Infisical ${resolvedArgs.writeInfisicalEnv}${slackInfisicalSecretPath} personal override keys: ${Object.keys(infisicalEnvVars).join(", ")}`, + ), + ); + } else { + await syncInfisicalLocalSlackSecrets({ + env: resolvedArgs.writeInfisicalEnv, + forceConfirmation: resolvedArgs.writeInfisicalEnv === "prod", + provider, + vars: infisicalEnvVars, + yes: resolvedArgs.yes, + }); + } + } + if (slackResponse?.oauth_authorize_url) { console.log( chalk.gray( @@ -771,6 +1193,11 @@ const setupAdminBot = async ({ args }: { args: Args }) => const setupLocalBot = async ({ args }: { args: Args }) => setupSlackBot({ args: { ...args, provider: "slack" } }); +const setupLocalAdminBot = async ({ args }: { args: Args }) => + setupSlackBot({ + args: { ...args, provider: "slack_admin" }, + }); + const prodBaseUrl = "https://api.useautumn.com"; const targetDefaults = ({ @@ -788,7 +1215,12 @@ const targetDefaults = ({ } if (target === "admin") { return { - appId: process.env.SLACK_ADMIN_APP_IDS ?? process.env.SLACK_ADMIN_APP_ID, + appId: + process.env.SLACK_ADMIN_APP_IDS ?? + process.env.SLACK_ADMIN_APP_ID ?? + resolveStoredLocalSlackAppId({ + provider: "slack_admin", + }), appName: process.env.SLACK_ADMIN_APP_NAME ?? "Autumn Chat Admin Local", baseUrl: resolveWorktreeNgrokUrl() ?? @@ -798,7 +1230,13 @@ const targetDefaults = ({ }; } return { - appId: process.env.SLACK_APP_ID ?? process.env.SLACK_LOCAL_APP_ID, + appId: + process.env.SLACK_APP_ID ?? + process.env.SLACK_LOCAL_APP_ID ?? + resolveStoredLocalSlackAppId({ + clientId: process.env.SLACK_CLIENT_ID, + provider: "slack", + }), appName: process.env.SLACK_APP_NAME ?? "Autumn Chat Local", baseUrl: resolveWorktreeNgrokUrl() ?? @@ -861,10 +1299,23 @@ const resolveManifestUpdateTarget = async ({ const updateManifestTargets = async ({ args }: { args: Args }) => { const target = args.target ?? "local"; - const targets = + const targets: Exclude[] = target === "all" ? (["local", "prod", "admin"] as const) - : ([target] as Exclude[]); + : args.action === "worktree" && target === "local" && !args.appId + ? (["local", "admin"] as const).filter((worktreeTarget) => + Boolean(targetDefaults({ target: worktreeTarget }).appId), + ) + : ([target] as Exclude[]); + + if (targets.length === 0) { + throw new Error( + [ + "No local Slack app ids found for worktree manifest update.", + "Run setup-local-bot/setup-local-admin-bot first, or pass --app-id .", + ].join("\n"), + ); + } if (!args.dryRun) { ensureSlackCli(); @@ -893,6 +1344,13 @@ const updateManifestTargets = async ({ args }: { args: Args }) => { if (args.printManifest || args.dryRun) { console.log(chalk.cyan(`\n${updateTarget} Slack app manifest:`)); + if (args.action === "worktree") { + console.log( + chalk.gray( + "Worktree updates preserve the existing Slack app name on real updates.", + ), + ); + } console.log(JSON.stringify(manifest, null, 2)); } @@ -901,9 +1359,18 @@ const updateManifestTargets = async ({ args }: { args: Args }) => { throw new Error(`Missing Slack app id for ${updateTarget} manifest`); } for (const appId of resolved.appIds) { + const manifestForApp = + args.action === "worktree" + ? await preserveSlackAppManifestNames({ + appId, + configToken, + manifest, + teamId: args.teamId, + }) + : manifest; await updateSlackAppManifest({ appId, - manifest, + manifest: manifestForApp, configToken, teamId: args.teamId, }); @@ -917,6 +1384,7 @@ const updateManifestTargets = async ({ args }: { args: Args }) => { const actions = { "setup-bot": setupSlackBot, "setup-admin-bot": setupAdminBot, + "setup-local-admin-bot": setupLocalAdminBot, "setup-local-bot": setupLocalBot, "setup-regular-bot": setupLocalBot, "update-admin-manifest": updateManifestTargets, diff --git a/server/tests/scenarios/agent/knowledge-platform-scenario.test.ts b/server/tests/scenarios/agent/knowledge-platform-scenario.test.ts new file mode 100644 index 000000000..5bf325d90 --- /dev/null +++ b/server/tests/scenarios/agent/knowledge-platform-scenario.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import chalk from "chalk"; +import { + initKnowledgePlatformScenario, + knowledgePlatformFeatureIds, + seedKnowledgePlatformCustomers, +} from "./knowledge-platform"; + +test(`${chalk.yellowBright("agent: knowledge platform setup with products, features, and entities")}`, async () => { + const { autumnV2_2, customerId, entities, plans } = + await initKnowledgePlatformScenario({ + customerId: "agent-knowledge-platform-smoke", + attachPlan: "trial", + entityCount: 2, + }); + + const customer = await autumnV2_2.customers.get(customerId); + const planIds = customer.subscriptions.map( + (subscription) => subscription.plan_id, + ); + + expect(planIds).toContain(plans.trial.id); + expect(entities).toHaveLength(2); + expect(entities[0].featureId).toBe(knowledgePlatformFeatureIds.workspaces); +}); + +const seedTest = + process.env.SEED_KNOWLEDGE_PLATFORM_CUSTOMERS === "true" ? test : test.skip; + +seedTest( + `${chalk.yellowBright("agent: seed knowledge platform org with realistic customers")}`, + async () => { + const customerCount = Number( + process.env.KNOWLEDGE_PLATFORM_CUSTOMER_COUNT ?? "1000", + ); + const result = await seedKnowledgePlatformCustomers({ customerCount }); + + expect(result.customerCount).toBe(customerCount); + expect(result.entityCount).toBeGreaterThanOrEqual(customerCount); + }, +); diff --git a/server/tests/scenarios/agent/knowledge-platform.ts b/server/tests/scenarios/agent/knowledge-platform.ts new file mode 100644 index 000000000..7d136d721 --- /dev/null +++ b/server/tests/scenarios/agent/knowledge-platform.ts @@ -0,0 +1,478 @@ +import { + FeatureUsageType, + type ProductItem, + type ProductV2, +} from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import defaultCtx, { + createTestContext, + type TestContext, +} from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { FeatureService } from "@/internal/features/FeatureService"; +import { + constructBooleanFeature, + constructCreditSystem, + constructMeteredFeature, +} from "@/internal/features/utils/constructFeatureUtils"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem"; +import { + buildRealisticCustomerSeed, + createScenarioAutumn, + seedCustomersWithEntities, +} from "../seedUtils"; + +export const knowledgePlatformFeatureIds = { + activity_events: "activity_events", + approval_chains: "approval_chains", + automation_rules: "automation_rules", + brand_controls: "brand_controls", + compliance_controls: "compliance_controls", + credits: "credits", + export_center: "export_center", + hosted_solution: "hosted_solution", + insight_reports: "insight_reports", + member_slots: "member_slots", + outbound_hooks: "outbound_hooks", + platform_api: "platform_api", + priority_queue: "priority_queue", + private_spaces: "private_spaces", + project_slots: "project_slots", + revision_history: "revision_history", + team_policies: "team_policies", + unlimited_seats: "unlimited_seats", + workspaces: "workspaces", +} as const; + +export const knowledgePlatformPlanIds = { + automationPack: "automation_pack", + enterprise: "enterprise", + launch: "launch", + scale: "scale", + scaleYearly: "scale_yearly", + securityPack: "security_pack", + trial: "trial", + whiteLabelPack: "white_label_pack", +} as const; + +export const knowledgePlatformPlatformFeatureIds = [ + knowledgePlatformFeatureIds.insight_reports, + knowledgePlatformFeatureIds.team_policies, + knowledgePlatformFeatureIds.private_spaces, + knowledgePlatformFeatureIds.export_center, + knowledgePlatformFeatureIds.priority_queue, + knowledgePlatformFeatureIds.automation_rules, + knowledgePlatformFeatureIds.outbound_hooks, + knowledgePlatformFeatureIds.platform_api, + knowledgePlatformFeatureIds.approval_chains, + knowledgePlatformFeatureIds.brand_controls, + knowledgePlatformFeatureIds.compliance_controls, + knowledgePlatformFeatureIds.revision_history, +] as const; + +export const knowledgePlatformContractFeatureIds = [ + knowledgePlatformFeatureIds.hosted_solution, + knowledgePlatformFeatureIds.unlimited_seats, +] as const; + +const featureNames: Partial> = { + activity_events: "Activity Events", + hosted_solution: "Hosted Solution", + unlimited_seats: "Unlimited Seats", + workspaces: "Workspaces", +}; + +type KnowledgePlatformFeatureId = + (typeof knowledgePlatformFeatureIds)[keyof typeof knowledgePlatformFeatureIds]; + +type KnowledgePlatformPlanMap = ReturnType< + typeof buildKnowledgePlatformProducts +>["plans"]; + +export type KnowledgePlatformPlanKey = keyof KnowledgePlatformPlanMap; + +const booleanItem = (featureId: KnowledgePlatformFeatureId): ProductItem => + constructFeatureItem({ + featureId, + isBoolean: true, + }) as ProductItem; + +const booleanItems = (featureIds: readonly KnowledgePlatformFeatureId[]) => + featureIds.map(booleanItem); + +const creditItems = () => [ + items.prepaid({ + featureId: knowledgePlatformFeatureIds.credits, + billingUnits: 1_000, + price: 100, + }), + items.consumable({ + featureId: knowledgePlatformFeatureIds.credits, + billingUnits: 1, + price: 0.1, + }), +]; + +export const buildKnowledgePlatformFeatures = ({ + ctx, +}: { + ctx: TestContext; +}) => { + const f = knowledgePlatformFeatureIds; + const orgId = ctx.org.id; + const env = ctx.env; + const booleanFeatureIds = [ + ...knowledgePlatformPlatformFeatureIds, + ...knowledgePlatformContractFeatureIds, + ]; + + return [ + constructMeteredFeature({ + featureId: f.activity_events, + name: featureNames[f.activity_events], + orgId, + env, + usageType: FeatureUsageType.Single, + eventNames: ["activity_events"], + }), + constructCreditSystem({ + featureId: f.credits, + orgId, + env, + schema: [{ metered_feature_id: f.activity_events, credit_cost: 1 }], + }), + constructMeteredFeature({ + featureId: f.member_slots, + orgId, + env, + usageType: FeatureUsageType.Continuous, + }), + constructMeteredFeature({ + featureId: f.project_slots, + orgId, + env, + usageType: FeatureUsageType.Continuous, + }), + constructMeteredFeature({ + featureId: f.workspaces, + name: featureNames[f.workspaces], + orgId, + env, + usageType: FeatureUsageType.Continuous, + }), + ...booleanFeatureIds.map((featureId) => + constructBooleanFeature({ + featureId, + name: featureNames[featureId], + orgId, + env, + }), + ), + ]; +}; + +export const ensureKnowledgePlatformFeatures = async ({ + ctx = defaultCtx, +}: { + ctx?: TestContext; +} = {}) => { + const desiredFeatures = buildKnowledgePlatformFeatures({ ctx }); + const existingFeatures = await FeatureService.list({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }); + const existingById = new Map( + existingFeatures.map((feature) => [feature.id, feature]), + ); + + const featuresToInsert = desiredFeatures.filter( + (feature) => !existingById.has(feature.id), + ); + const featuresToUpdate = desiredFeatures.filter((feature) => + existingById.has(feature.id), + ); + + if (featuresToInsert.length > 0) { + await FeatureService.insert({ + db: ctx.db, + data: featuresToInsert, + logger: console, + }); + } + + await Promise.all( + featuresToUpdate.map((feature) => + FeatureService.update({ + db: ctx.db, + id: feature.id, + orgId: ctx.org.id, + env: ctx.env, + updates: { + name: feature.name, + type: feature.type, + config: feature.config, + event_names: feature.event_names, + model_markups: feature.model_markups, + archived: false, + }, + }), + ), + ); + + ctx.features = await FeatureService.list({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }); + + return ctx.features.filter((feature) => + desiredFeatures.some((desired) => desired.id === feature.id), + ); +}; + +export const buildKnowledgePlatformProducts = () => { + const f = knowledgePlatformFeatureIds; + const p = knowledgePlatformPlanIds; + const coreFeatures = [ + f.insight_reports, + f.team_policies, + f.private_spaces, + f.export_center, + f.automation_rules, + f.platform_api, + ] as const; + const expandedFeatures = [ + ...coreFeatures, + f.priority_queue, + f.outbound_hooks, + f.approval_chains, + f.brand_controls, + f.compliance_controls, + f.revision_history, + ] as const; + + const plans = { + launch: products.base({ + id: p.launch, + items: [ + items.monthlyPrice({ price: 300 }), + ...creditItems(), + ...booleanItems(coreFeatures), + ], + }), + scale: products.base({ + id: p.scale, + items: [ + items.monthlyPrice({ price: 500 }), + ...creditItems(), + ...booleanItems(expandedFeatures), + ], + }), + scaleYearly: products.base({ + id: p.scaleYearly, + items: [ + items.annualPrice({ price: 5_000 }), + ...creditItems(), + ...booleanItems(expandedFeatures), + ], + }), + trial: products.base({ + id: p.trial, + items: [ + items.free({ featureId: f.credits, includedUsage: 1_000 }), + ...booleanItems([f.insight_reports, f.private_spaces, f.platform_api]), + ], + }), + enterprise: products.base({ + id: p.enterprise, + items: [ + ...creditItems(), + items.free({ featureId: f.member_slots, includedUsage: 25 }), + items.free({ featureId: f.project_slots, includedUsage: 100 }), + ...booleanItems(expandedFeatures), + ], + }), + automationPack: products.base({ + id: p.automationPack, + isAddOn: true, + items: [ + items.monthlyPrice({ price: 75 }), + booleanItem(f.automation_rules), + ], + }), + securityPack: products.base({ + id: p.securityPack, + isAddOn: true, + items: [ + items.annualPrice({ price: 2_400 }), + booleanItem(f.compliance_controls), + ], + }), + whiteLabelPack: products.base({ + id: p.whiteLabelPack, + isAddOn: true, + items: [ + items.annualPrice({ price: 3_000 }), + booleanItem(f.brand_controls), + ], + }), + } satisfies Record; + + return { + featureIds: knowledgePlatformFeatureIds, + planIds: knowledgePlatformPlanIds, + coreFeatures, + expandedFeatures, + plans, + }; +}; + +export const initKnowledgePlatformScenario = async ({ + customerId = "agent-knowledge-platform", + attachPlan = "trial", + entityCount = 2, + paymentMethod = "success", + ctx = defaultCtx, +}: { + customerId?: string; + attachPlan?: KnowledgePlatformPlanKey | null; + entityCount?: number; + paymentMethod?: "success" | "fail" | "authenticate" | "alipay"; + ctx?: TestContext; +} = {}) => { + await ensureKnowledgePlatformFeatures({ ctx }); + + const catalog = buildKnowledgePlatformProducts(); + const setup = [ + s.customer({ paymentMethod }), + s.products({ list: Object.values(catalog.plans) }), + ...(entityCount > 0 + ? [ + s.entities({ + count: entityCount, + featureId: catalog.featureIds.workspaces, + }), + ] + : []), + ]; + const actions = attachPlan + ? [s.billing.attach({ productId: catalog.plans[attachPlan].id })] + : []; + const scenario = await initScenario({ + customerId, + setup, + actions, + ctx, + }); + + return { + ...scenario, + ...catalog, + }; +}; + +export const seedKnowledgePlatformCustomers = async ({ + customerCount = 1_000, + idPrefix = "kp-customer", + entityCountForCustomer, + productPrefix = "knowledge-platform", + attachPlan = null, + concurrency = 10, + deleteExisting = true, + ctx = defaultCtx, +}: { + customerCount?: number; + idPrefix?: string; + entityCountForCustomer?: (index: number) => 1 | 2; + productPrefix?: string; + attachPlan?: Extract | null; + concurrency?: number; + deleteExisting?: boolean; + ctx?: TestContext; +} = {}) => { + await ensureKnowledgePlatformFeatures({ ctx }); + + const catalog = buildKnowledgePlatformProducts(); + await initScenario({ + setup: [ + s.products({ + list: Object.values(catalog.plans), + prefix: productPrefix, + createInStripe: false, + }), + ], + actions: [], + ctx, + }); + + const customers = Array.from({ length: customerCount }, (_, index) => { + return { + ...buildRealisticCustomerSeed({ + index, + idPrefix, + entityFeatureId: knowledgePlatformFeatureIds.workspaces, + entityCount: entityCountForCustomer?.(index), + }), + attachPlanId: attachPlan ? catalog.plans[attachPlan].id : null, + }; + }); + + const seeded = await seedCustomersWithEntities({ + autumn: createScenarioAutumn({ ctx }), + customers, + concurrency, + deleteExisting, + }); + + return { + ...catalog, + productPrefix, + ...seeded, + }; +}; + +const getArgValue = (name: string) => { + const prefix = `${name}=`; + const inline = process.argv.find((arg) => arg.startsWith(prefix)); + if (inline) return inline.slice(prefix.length); + + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +const runKnowledgePlatformSeed = async () => { + const customerCount = Number(getArgValue("--count") ?? "1000"); + const concurrency = Number(getArgValue("--concurrency") ?? "10"); + const attachPlan = getArgValue("--attach-plan") as + | Extract + | undefined; + const ctx = await createTestContext(); + + const result = await seedKnowledgePlatformCustomers({ + ctx, + customerCount, + concurrency, + attachPlan: attachPlan ?? null, + deleteExisting: !process.argv.includes("--keep-existing"), + }); + + console.log("Knowledge platform seed complete", { + customers: result.customerCount, + entities: result.entityCount, + productPrefix: result.productPrefix, + attachPlan: attachPlan ?? null, + }); +}; + +if (import.meta.main) { + runKnowledgePlatformSeed() + .catch((error) => { + console.error("Knowledge platform seed failed:", error); + process.exit(1); + }) + .finally(() => { + process.exit(0); + }); +} diff --git a/server/tests/scenarios/seedUtils/concurrency.ts b/server/tests/scenarios/seedUtils/concurrency.ts new file mode 100644 index 000000000..3cde256b1 --- /dev/null +++ b/server/tests/scenarios/seedUtils/concurrency.ts @@ -0,0 +1,25 @@ +export const mapWithConcurrency = async ({ + list, + concurrency, + fn, +}: { + list: T[]; + concurrency: number; + fn: (item: T, index: number) => Promise; +}) => { + const results: R[] = []; + let nextIndex = 0; + const workerCount = Math.min(Math.max(concurrency, 1), list.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < list.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await fn(list[currentIndex], currentIndex); + } + }), + ); + + return results; +}; diff --git a/server/tests/scenarios/seedUtils/customerSeedTypes.ts b/server/tests/scenarios/seedUtils/customerSeedTypes.ts new file mode 100644 index 000000000..f6a6b39db --- /dev/null +++ b/server/tests/scenarios/seedUtils/customerSeedTypes.ts @@ -0,0 +1,23 @@ +import type { + CreateCustomerInternalOptions, + CreateEntityParams, +} from "@autumn/shared"; + +export type SeedEntityInput = { + id: string; + name: string; + featureId: string; + customerData?: CreateEntityParams["customer_data"]; +}; + +export type SeedCustomerInput = { + id: string; + name: string; + email: string; + metadata?: Record; + entities: SeedEntityInput[]; + createInStripe?: boolean; + internalOptions?: CreateCustomerInternalOptions; + skipWebhooks?: boolean; + attachPlanId?: string | null; +}; diff --git a/server/tests/scenarios/seedUtils/index.ts b/server/tests/scenarios/seedUtils/index.ts new file mode 100644 index 000000000..012c98253 --- /dev/null +++ b/server/tests/scenarios/seedUtils/index.ts @@ -0,0 +1,4 @@ +export * from "./concurrency"; +export * from "./customerSeedTypes"; +export * from "./realisticData"; +export * from "./seedCustomers"; diff --git a/server/tests/scenarios/seedUtils/realisticData.ts b/server/tests/scenarios/seedUtils/realisticData.ts new file mode 100644 index 000000000..0cf0750d6 --- /dev/null +++ b/server/tests/scenarios/seedUtils/realisticData.ts @@ -0,0 +1,185 @@ +import type { SeedCustomerInput } from "./customerSeedTypes"; + +const industries = [ + "Analytics", + "Architecture", + "Biotech", + "Commerce", + "Compliance", + "Education", + "Energy", + "Fintech", + "Healthcare", + "Logistics", + "Media", + "Robotics", +] as const; + +const companyPrefixes = [ + "Acme", + "Aperture", + "Atlas", + "Bluebird", + "Brightline", + "Cedar", + "Cobalt", + "Evergreen", + "Fable", + "Harbor", + "Juniper", + "Lattice", + "Northstar", + "Oakline", + "Prairie", + "Redwood", + "Signal", + "Summit", + "Terra", + "Waypoint", +] as const; + +const companySuffixes = [ + "Analytics", + "Cloud", + "Collective", + "Data", + "Dynamics", + "Group", + "Health", + "Labs", + "Logistics", + "Media", + "Research", + "Systems", + "Works", +] as const; + +const ownerFirstNames = [ + "Alex", + "Amara", + "Ben", + "Camille", + "Daniel", + "Elena", + "Fatima", + "Grace", + "Hannah", + "Ivan", + "Jonah", + "Leah", + "Maya", + "Nadia", + "Owen", + "Priya", + "Rafael", + "Sofia", + "Theo", + "Vivian", +] as const; + +const ownerLastNames = [ + "Brooks", + "Chen", + "Diaz", + "Evans", + "Foster", + "Grant", + "Hughes", + "Iyer", + "Kim", + "Lawson", + "Morgan", + "Patel", + "Reed", + "Singh", + "Stone", + "Turner", + "Wong", + "Young", +] as const; + +const regions = [ + "AMER", + "APAC", + "Benelux", + "DACH", + "EMEA", + "LATAM", + "Northern Europe", + "Southern Europe", +] as const; + +const lifecycleStages = [ + "evaluation", + "implementation", + "launched", + "expansion", + "renewal", +] as const; + +const workspaceNames = [ + "Billing Ops", + "Customer Success", + "Developer Platform", + "Enterprise Rollout", + "Finance Systems", + "Growth Experiments", + "Knowledge Base", + "Operations", + "Partner Portal", + "Research", + "Security Review", + "Support Desk", +] as const; + +export type RealisticCustomerSeed = SeedCustomerInput & { + entityCount: number; +}; + +const pick = (list: readonly T[], index: number) => + list[index % list.length]; + +const slugify = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +export const buildRealisticCustomerSeed = ({ + index, + idPrefix, + entityFeatureId, + entityCount = index % 3 === 0 ? 2 : 1, +}: { + index: number; + idPrefix: string; + entityFeatureId: string; + entityCount?: 1 | 2; +}): RealisticCustomerSeed => { + const name = `${pick(companyPrefixes, index)} ${pick(companySuffixes, Math.floor(index / companyPrefixes.length))}`; + const id = `${idPrefix}-${String(index + 1).padStart(4, "0")}`; + const domain = `${slugify(name)}.example`; + const entities = Array.from({ length: entityCount }, (_, entityIndex) => ({ + id: `${id}-workspace-${entityIndex + 1}`, + name: `${pick(workspaceNames, index + entityIndex)} Workspace`, + featureId: entityFeatureId, + })); + + return { + id, + name, + email: `billing+${id}@${domain}`, + entityCount, + entities, + createInStripe: false, + internalOptions: { disable_defaults: true }, + skipWebhooks: true, + metadata: { + account_owner: `${pick(ownerFirstNames, index)} ${pick(ownerLastNames, index)}`, + company_size: 25 + ((index * 37) % 975), + industry: pick(industries, index), + lifecycle_stage: pick(lifecycleStages, index), + region: pick(regions, index), + }, + }; +}; diff --git a/server/tests/scenarios/seedUtils/seedCustomers.ts b/server/tests/scenarios/seedUtils/seedCustomers.ts new file mode 100644 index 000000000..c118ffc8c --- /dev/null +++ b/server/tests/scenarios/seedUtils/seedCustomers.ts @@ -0,0 +1,93 @@ +import { + ApiVersion, + type CreateEntityParams, + type LegacyVersion, +} from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli"; +import { mapWithConcurrency } from "./concurrency"; +import type { SeedCustomerInput } from "./customerSeedTypes"; + +export const createScenarioAutumn = ({ + ctx, + version = ApiVersion.V1_2, +}: { + ctx: TestContext; + version?: string | LegacyVersion; +}) => + new AutumnInt({ + version, + secretKey: ctx.orgSecretKey, + }); + +export const seedCustomersWithEntities = async < + TCustomer extends SeedCustomerInput, +>({ + autumn, + customers, + concurrency = 10, + deleteExisting = true, +}: { + autumn: AutumnInt; + customers: TCustomer[]; + concurrency?: number; + deleteExisting?: boolean; +}) => { + const seededCustomers = await mapWithConcurrency({ + list: customers, + concurrency, + fn: async (customer) => { + if (deleteExisting) { + try { + await autumn.customers.delete(customer.id); + } catch {} + } + + await autumn.customers.create({ + id: customer.id, + name: customer.name, + email: customer.email, + metadata: customer.metadata, + create_in_stripe: customer.createInStripe ?? false, + internalOptions: customer.internalOptions ?? { + disable_defaults: true, + }, + skipWebhooks: customer.skipWebhooks ?? true, + }); + + if (customer.entities.length > 0) { + const entityPayloads = customer.entities.map( + (entity): CreateEntityParams => ({ + id: entity.id, + name: entity.name, + feature_id: entity.featureId, + customer_data: entity.customerData, + }), + ); + + await autumn.entities.create(customer.id, entityPayloads); + } + + if (customer.attachPlanId) { + await autumn.billing.attach( + { + customer_id: customer.id, + product_id: customer.attachPlanId, + }, + { skipWebhooks: customer.skipWebhooks ?? true, timeout: 0 }, + ); + } + + return customer; + }, + }); + + return { + customerCount: seededCustomers.length, + entityCount: seededCustomers.reduce( + (total, customer) => total + customer.entities.length, + 0, + ), + customers: seededCustomers, + }; +};