This commit is contained in:
johnyeo
2026-06-11 19:03:23 +01:00
parent f6e4e7b405
commit 76096a57c9
11 changed files with 1409 additions and 77 deletions

View File

@@ -7,10 +7,16 @@
// ENV_FILE=.env infisical run --env=dev --recursive -- \
// bun apps/leaf/scripts/seedSlackInstall.ts (DATABASE_URL=<worktree>)
//
// 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;
}

View File

@@ -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
// `@<version>` 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");

View File

@@ -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<void> {
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<string, string>),
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<string, string>),
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<void> {
// 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<void> {
export async function autoSeedSlackInstall(
entry: RegistryEntry,
): Promise<void> {
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<string, string>),
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<string, string>),
DATABASE_URL: entry.databaseUrl,
DATABASE_CRITICAL_URL: entry.databaseUrl,
},
);
});
if (code !== 0) {
console.error(`[dw] seedSlackInstall exited with code ${code}; continuing`);
}

View File

@@ -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<string, { ngrokUrl?: string }>;
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 <command> [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 <local|prod|admin|all>.",
"",
chalk.bold("Options:"),
" --app-id <id> Existing Slack app id for manifest updates.",
" --base-url <url> Public Leaf URL. Defaults to this worktree's NGROK_URL,",
" then SLACK_BOT_URL / CHAT_URL.",
" --base-url <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 <name> Slack app name. Defaults to Autumn Chat Local.",
" --env-file <path> Write Slack env vars to this file.",
" --provider <provider> slack or slack_admin (setup-bot only).",
" --scopes <csv> Override bot scopes.",
" --target <target> update-manifest target: local, prod, admin, or all.",
" --team-id <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<Args & { provider: SlackInstallProvider }> => {
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 = <T>({
// 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<SlackManifest | undefined> => {
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<SlackManifestExportResponse>({
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<SlackManifest> => {
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<string>(localSlackInfisicalKeys);
const assertSafeInfisicalSlackWrite = ({
vars,
}: {
vars: Record<string, string>;
}) => {
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, "<redacted>");
}
return redacted;
};
const formatEnvFile = ({ vars }: { vars: Record<string, string> }) =>
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<string, string>;
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 ?? "<app-id-from-slack>",
SLACK_CLIENT_ID: clientId ?? "<client-id-from-slack>",
SLACK_CLIENT_SECRET: clientSecret ?? "<client-secret-from-slack>",
SLACK_SIGNING_SECRET: signingSecret ?? "<signing-secret-from-slack>",
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<SlackManifestTarget, "all">[] =
target === "all"
? (["local", "prod", "admin"] as const)
: ([target] as Exclude<SlackManifestTarget, "all">[]);
: args.action === "worktree" && target === "local" && !args.appId
? (["local", "admin"] as const).filter((worktreeTarget) =>
Boolean(targetDefaults({ target: worktreeTarget }).appId),
)
: ([target] as Exclude<SlackManifestTarget, "all">[]);
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 <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,

View File

@@ -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<ApiCustomerV5>(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);
},
);

View File

@@ -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<Record<KnowledgePlatformFeatureId, string>> = {
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<string, ProductV2>;
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<KnowledgePlatformPlanKey, "trial" | "enterprise"> | 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<KnowledgePlatformPlanKey, "trial" | "enterprise">
| 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);
});
}

View File

@@ -0,0 +1,25 @@
export const mapWithConcurrency = async <T, R>({
list,
concurrency,
fn,
}: {
list: T[];
concurrency: number;
fn: (item: T, index: number) => Promise<R>;
}) => {
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;
};

View File

@@ -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<string, unknown>;
entities: SeedEntityInput[];
createInStripe?: boolean;
internalOptions?: CreateCustomerInternalOptions;
skipWebhooks?: boolean;
attachPlanId?: string | null;
};

View File

@@ -0,0 +1,4 @@
export * from "./concurrency";
export * from "./customerSeedTypes";
export * from "./realisticData";
export * from "./seedCustomers";

View File

@@ -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 = <T>(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),
},
};
};

View File

@@ -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,
};
};