diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f5b94..8c3b4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.9.0] - 2026-04-08 + +- prompt for `${VAR}` template values in `--env` and `--header` flags during interactive mode +- prompt for package environment variables, headers, and arguments during `find` search installs + ## [1.8.1] - 2026-04-07 - fix `find` / `search` package installs to stop pinning npm versions and resolve latest implicitly diff --git a/package.json b/package.json index 1e7692a..ac52a40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "add-mcp", - "version": "1.8.1", + "version": "1.9.0", "description": "Add MCP servers to your favorite coding agents with a single command.", "author": "Andre Landgraf ", "license": "Apache-2.0", @@ -16,8 +16,8 @@ "fmt": "prettier --write .", "build": "tsup src/index.ts --format esm --dts --clean", "dev": "tsx src/index.ts", - "test": "tsx tests/source-parser.test.ts && tsx tests/agents.test.ts && tsx tests/config.test.ts && tsx tests/installer.test.ts && tsx tests/find.test.ts && tsx tests/reader.test.ts && tsx tests/formats-remove.test.ts && tsx tests/e2e/install.test.ts && tsx tests/e2e/cli.test.ts", - "test:unit": "tsx tests/source-parser.test.ts && tsx tests/agents.test.ts && tsx tests/config.test.ts && tsx tests/installer.test.ts && tsx tests/find.test.ts && tsx tests/reader.test.ts && tsx tests/formats-remove.test.ts", + "test": "tsx tests/source-parser.test.ts && tsx tests/agents.test.ts && tsx tests/config.test.ts && tsx tests/installer.test.ts && tsx tests/find.test.ts && tsx tests/template.test.ts && tsx tests/reader.test.ts && tsx tests/formats-remove.test.ts && tsx tests/e2e/install.test.ts && tsx tests/e2e/cli.test.ts", + "test:unit": "tsx tests/source-parser.test.ts && tsx tests/agents.test.ts && tsx tests/config.test.ts && tsx tests/installer.test.ts && tsx tests/find.test.ts && tsx tests/template.test.ts && tsx tests/reader.test.ts && tsx tests/formats-remove.test.ts", "registry:sort": "tsx scripts/sort-registry.ts", "registry:verify": "tsx scripts/verify-registry.ts", "test:e2e": "tsx tests/e2e/install.test.ts && tsx tests/e2e/cli.test.ts", diff --git a/src/find.ts b/src/find.ts index 2960489..0f649af 100644 --- a/src/find.ts +++ b/src/find.ts @@ -29,11 +29,30 @@ export interface RegistryPackageDefinition { registryType: "npm" | "oci" | "nuget" | "mcpb"; identifier: string; version?: string; + environmentVariables?: RegistryNamedVariableDefinition[]; + headers?: RegistryHeaderDefinition[]; + args?: RegistryPackageArgumentDefinition[]; + arguments?: RegistryPackageArgumentDefinition[]; + commandArguments?: RegistryPackageArgumentDefinition[]; transport: { type: "stdio"; }; } +export interface RegistryNamedVariableDefinition extends RegistryVariableDefinition { + name: string; +} + +export interface RegistryPackageArgumentDefinition { + name?: string; + value?: string; + description?: string; + isRequired?: boolean; + isSecret?: boolean; + default?: string; + choices?: string[]; +} + export interface RegistryServerEntry { name: string; title?: string; @@ -55,6 +74,8 @@ export interface FindInstallPlan { serverName: string; transport?: TransportType; headers?: Record; + env?: Record; + args?: string[]; } export interface PromptField { @@ -433,6 +454,41 @@ function headerFields( })); } +function packageVariableFields( + variables: RegistryNamedVariableDefinition[] | undefined, +): PromptField[] { + if (!variables) return []; + return variables + .filter((variable) => variable.name && variable.name.trim().length > 0) + .map((variable) => ({ + key: variable.name, + label: `Environment variable ${variable.name}`, + isRequired: variable.isRequired === true, + placeholder: buildPlaceholderValue("variable"), + })); +} + +function packageArgumentFields(pkg: RegistryPackageDefinition): PromptField[] { + const definitions = [ + ...(pkg.args ?? []), + ...(pkg.arguments ?? []), + ...(pkg.commandArguments ?? []), + ]; + return definitions.map((arg, index) => { + const descriptor = + arg.name?.trim() || + arg.value?.trim() || + arg.description?.trim() || + `#${index + 1}`; + return { + key: String(index), + label: `Argument ${descriptor}`, + isRequired: arg.isRequired === true, + placeholder: buildPlaceholderValue("variable"), + }; + }); +} + function resolveNonInteractiveRemote(remote: RegistryRemoteDefinition): { url: string; headers?: Record; @@ -481,6 +537,74 @@ async function resolveInteractiveRemote( }; } +function resolveNonInteractivePackage(pkg: RegistryPackageDefinition): { + env?: Record; + headers?: Record; + args?: string[]; +} { + const env: Record = {}; + for (const field of packageVariableFields(pkg.environmentVariables)) { + if (field.isRequired) { + env[field.key] = field.placeholder; + } + } + + const headers: Record = {}; + for (const field of headerFields(pkg.headers)) { + if (field.isRequired) { + headers[field.key] = field.placeholder; + } + } + + const args = packageArgumentFields(pkg) + .filter((field) => field.isRequired) + .map((field) => field.placeholder); + + return { + env: Object.keys(env).length > 0 ? env : undefined, + headers: Object.keys(headers).length > 0 ? headers : undefined, + args: args.length > 0 ? args : undefined, + }; +} + +async function resolveInteractivePackage( + pkg: RegistryPackageDefinition, +): Promise<{ + env?: Record; + headers?: Record; + args?: string[]; +} | null> { + const envResult = await collectPromptValues( + packageVariableFields(pkg.environmentVariables), + promptValue, + ); + if (envResult.cancelled) return null; + + const headerResult = await collectPromptValues( + headerFields(pkg.headers), + promptValue, + ); + if (headerResult.cancelled) return null; + + const argumentFields = packageArgumentFields(pkg); + const argsResult = await collectPromptValues(argumentFields, promptValue); + if (argsResult.cancelled) return null; + + const args = argumentFields + .map((field) => argsResult.values[field.key]) + .filter((value): value is string => typeof value === "string"); + + return { + env: + Object.keys(envResult.values).length > 0 ? envResult.values : undefined, + headers: + Object.keys(headerResult.values).length > 0 + ? headerResult.values + : undefined, + args: args.length > 0 ? args : undefined, + }; +} + export async function buildInstallPlanForEntry( entry: RegistryServerEntry, options: FindCommandOptions, @@ -519,9 +643,17 @@ export async function buildInstallPlanForEntry( } if (mode === "package" && pkg) { + const resolved = options.yes + ? resolveNonInteractivePackage(pkg) + : await resolveInteractivePackage(pkg); + if (!resolved) return null; + return { target: formatPackageTarget(pkg), serverName: resolveServerName(entry), + env: resolved.env, + headers: resolved.headers, + args: resolved.args, }; } diff --git a/src/index.ts b/src/index.ts index 29e8033..d744007 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,6 +149,7 @@ interface Options { type?: string; header?: string[]; env?: string[]; + args?: string[]; yes?: boolean; all?: boolean; gitignore?: boolean; @@ -236,6 +237,27 @@ function extractSubcommandOptionsFromArgv(): Partial { result.gitignore = true; continue; } + if (arg === "--header" && argv[i + 1]) { + const headers: string[] = result.header ? [...result.header] : []; + headers.push(argv[i + 1]!); + result.header = headers; + i += 1; + continue; + } + if (arg === "--env" && argv[i + 1]) { + const env: string[] = result.env ? [...result.env] : []; + env.push(argv[i + 1]!); + result.env = env; + i += 1; + continue; + } + if (arg === "--args" && argv[i + 1]) { + const args: string[] = result.args ? [...result.args] : []; + args.push(argv[i + 1]!); + result.args = args; + i += 1; + continue; + } if ((arg === "-n" || arg === "--name") && argv[i + 1]) { result.name = argv[i + 1]; i += 1; @@ -357,6 +379,8 @@ function parseEnv(values: string[]): ParsedEnvResult { return { env, invalid }; } +import { hasTemplateVars, resolveRecordTemplates } from "./template.js"; + program .name("add-mcp") .description( @@ -390,6 +414,12 @@ program collect, [], ) + .option( + "--args ", + "Argument for local stdio servers (repeatable)", + collect, + [], + ) .option("-y, --yes", "Skip confirmation prompts") .option("--all", "Install to all agents") .option("--gitignore", "Add generated project config files to .gitignore") @@ -440,6 +470,10 @@ async function runFindCommand( ([key, value]) => `${key}: ${value}`, ) : options.header, + env: installPlan.env + ? Object.entries(installPlan.env).map(([key, value]) => `${key}=${value}`) + : options.env, + args: installPlan.args ?? options.args, }; await main(installPlan.target, mergedOptions); @@ -1258,6 +1292,52 @@ async function main(target: string | undefined, options: Options) { ); } + const argsValues = options.args ?? []; + const hasArgsValues = argsValues.length > 0; + if (hasArgsValues && isRemote) { + p.log.warn( + "--args is only used for local/package/command installs, ignoring", + ); + } + + const promptTemplateVar = (varName: string) => + p.text({ + message: `Enter value for ${varName}`, + placeholder: `<${varName}>`, + }); + + if ( + !options.yes && + hasHeaderValues && + hasTemplateVars(headerResult.headers) + ) { + const result = await resolveRecordTemplates( + headerResult.headers, + promptTemplateVar, + ); + if (result.cancelled) { + p.cancel("Cancelled"); + process.exit(0); + } + for (const [key, value] of Object.entries(result.resolved)) { + headerResult.headers[key] = value; + } + } + + if (!options.yes && hasEnvValues && hasTemplateVars(envResult.env)) { + const result = await resolveRecordTemplates( + envResult.env, + promptTemplateVar, + ); + if (result.cancelled) { + p.cancel("Cancelled"); + process.exit(0); + } + for (const [key, value] of Object.entries(result.resolved)) { + envResult.env[key] = value; + } + } + // Determine server name const serverName = options.name || parsed.inferredName; p.log.info(`Server name: ${chalk.cyan(serverName)}`); @@ -1285,6 +1365,7 @@ async function main(target: string | undefined, options: Options) { transport: resolvedTransport, headers: isRemote && hasHeaderValues ? headerResult.headers : undefined, env: !isRemote && hasEnvValues ? envResult.env : undefined, + args: !isRemote && hasArgsValues ? argsValues : undefined, }); // Determine target agents diff --git a/src/installer.ts b/src/installer.ts index 919dca9..8a3c7ca 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -37,6 +37,8 @@ export interface BuildServerConfigOptions { headers?: Record; /** Environment variables for local stdio servers */ env?: Record; + /** Extra command arguments for local stdio servers */ + args?: string[]; } export interface UpdateGitignoreOptions { @@ -69,7 +71,7 @@ export function buildServerConfig( if (parsed.type === "command") { const parts = parsed.value.split(" "); const command = parts[0]!; - const args = parts.slice(1); + const args = [...parts.slice(1), ...(options.args ?? [])]; const config: McpServerConfig = { command, args, @@ -84,7 +86,7 @@ export function buildServerConfig( const config: McpServerConfig = { command: "npx", - args: ["-y", parsed.value], + args: ["-y", parsed.value, ...(options.args ?? [])], }; if (options.env && Object.keys(options.env).length > 0) { diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..79d7661 --- /dev/null +++ b/src/template.ts @@ -0,0 +1,61 @@ +const TEMPLATE_PATTERN = /\$\{([^}]+)\}/g; + +export function findTemplateVars(value: string): string[] { + const vars: string[] = []; + let match: RegExpExecArray | null; + while ((match = TEMPLATE_PATTERN.exec(value)) !== null) { + vars.push(match[1]!); + } + TEMPLATE_PATTERN.lastIndex = 0; + return vars; +} + +export async function resolveTemplates( + value: string, + ask: (varName: string) => Promise, +): Promise<{ resolved: string; cancelled: boolean }> { + const vars = findTemplateVars(value); + if (vars.length === 0) return { resolved: value, cancelled: false }; + + let result = value; + for (const varName of vars) { + const answer = await ask(varName); + if (typeof answer === "symbol") return { resolved: value, cancelled: true }; + const entered = typeof answer === "string" ? answer : ""; + result = result.replace(`\${${varName}}`, entered); + } + return { resolved: result, cancelled: false }; +} + +export async function resolveRecordTemplates( + record: Record, + ask: (varName: string) => Promise, +): Promise<{ resolved: Record; cancelled: boolean }> { + const resolved: Record = {}; + for (const [key, value] of Object.entries(record)) { + const result = await resolveTemplates(value, ask); + if (result.cancelled) return { resolved: record, cancelled: true }; + resolved[key] = result.resolved; + } + return { resolved, cancelled: false }; +} + +export async function resolveArrayTemplates( + values: string[], + ask: (varName: string) => Promise, +): Promise<{ resolved: string[]; cancelled: boolean }> { + const resolved: string[] = []; + for (const value of values) { + const result = await resolveTemplates(value, ask); + if (result.cancelled) return { resolved: values, cancelled: true }; + resolved.push(result.resolved); + } + return { resolved, cancelled: false }; +} + +export function hasTemplateVars( + values: Record | string[], +): boolean { + const strings = Array.isArray(values) ? values : Object.values(values); + return strings.some((v) => findTemplateVars(v).length > 0); +} diff --git a/tests/find.test.ts b/tests/find.test.ts index 4338855..caefe9b 100644 --- a/tests/find.test.ts +++ b/tests/find.test.ts @@ -685,6 +685,147 @@ test("buildInstallPlanForEntry returns package target for package-only entry", a assert.strictEqual(plan?.target, "@sentry/mcp-server"); assert.strictEqual(plan?.transport, undefined); assert.strictEqual(plan?.headers, undefined); + assert.strictEqual(plan?.env, undefined); + assert.strictEqual(plan?.args, undefined); +}); + +test("buildInstallPlanForEntry includes only required package env/header/args placeholders in -y mode", async () => { + const plan = await buildInstallPlanForEntry( + { + name: "com.example/with-inputs", + description: "Package with optional and required values", + version: "1.0.0", + package: { + registryType: "npm", + identifier: "@example/with-inputs", + transport: { type: "stdio" }, + environmentVariables: [ + { name: "REQUIRED_ENV", isRequired: true }, + { name: "OPTIONAL_ENV", isRequired: false }, + ], + headers: [ + { name: "Authorization", isRequired: true }, + { name: "X-Optional", isRequired: false }, + ], + args: [ + { name: "--required-arg", isRequired: true }, + { name: "--optional-arg", isRequired: false }, + ], + }, + }, + { yes: true }, + ); + + assert.ok(plan); + assert.strictEqual(plan?.target, "@example/with-inputs"); + assert.deepStrictEqual(plan?.env, { + REQUIRED_ENV: "", + }); + assert.deepStrictEqual(plan?.headers, { + Authorization: "", + }); + assert.deepStrictEqual(plan?.args, [""]); +}); + +test("buildInstallPlanForEntry omits env/headers/args when all package inputs are optional in -y mode", async () => { + const plan = await buildInstallPlanForEntry( + { + name: "com.example/all-optional", + description: "Package with only optional values", + version: "1.0.0", + package: { + registryType: "npm", + identifier: "@example/all-optional", + transport: { type: "stdio" }, + environmentVariables: [{ name: "OPT_ENV", isRequired: false }], + headers: [{ name: "X-Optional", isRequired: false }], + args: [{ name: "--verbose", isRequired: false }], + }, + }, + { yes: true }, + ); + + assert.ok(plan); + assert.strictEqual(plan?.target, "@example/all-optional"); + assert.strictEqual(plan?.env, undefined); + assert.strictEqual(plan?.headers, undefined); + assert.strictEqual(plan?.args, undefined); +}); + +test("buildInstallPlanForEntry merges arguments and commandArguments fields in -y mode", async () => { + const plan = await buildInstallPlanForEntry( + { + name: "com.example/multi-arg-fields", + description: "Package using arguments and commandArguments", + version: "1.0.0", + package: { + registryType: "npm", + identifier: "@example/multi-arg-fields", + transport: { type: "stdio" }, + arguments: [{ name: "--from-arguments", isRequired: true }], + commandArguments: [ + { name: "--from-command-arguments", isRequired: true }, + ], + }, + }, + { yes: true }, + ); + + assert.ok(plan); + assert.deepStrictEqual(plan?.args, [ + "", + "", + ]); +}); + +test("buildInstallPlanForEntry filters blank-name env variables in -y mode", async () => { + const plan = await buildInstallPlanForEntry( + { + name: "com.example/blank-env", + description: "Package with blank env names", + version: "1.0.0", + package: { + registryType: "npm", + identifier: "@example/blank-env", + transport: { type: "stdio" }, + environmentVariables: [ + { name: "", isRequired: true }, + { name: " ", isRequired: true }, + { name: "VALID_KEY", isRequired: true }, + ], + }, + }, + { yes: true }, + ); + + assert.ok(plan); + assert.deepStrictEqual(plan?.env, { + VALID_KEY: "", + }); +}); + +test("buildInstallPlanForEntry uses arg value/description as label fallback in -y mode", async () => { + const plan = await buildInstallPlanForEntry( + { + name: "com.example/arg-fallbacks", + description: "Package with various arg descriptors", + version: "1.0.0", + package: { + registryType: "npm", + identifier: "@example/arg-fallbacks", + transport: { type: "stdio" }, + args: [ + { value: "/path/to/db", isRequired: true }, + { description: "The workspace directory", isRequired: true }, + { isRequired: true }, + ], + }, + }, + { yes: true }, + ); + + assert.ok(plan); + assert.strictEqual(plan?.args?.length, 3); }); test("buildInstallPlanForEntry returns null when entry has no remotes or packages", async () => { diff --git a/tests/installer.test.ts b/tests/installer.test.ts index c6f9364..cbc8ca5 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -159,6 +159,42 @@ test("buildServerConfig - package includes env when provided", () => { }); }); +test("buildServerConfig - package appends args when provided", () => { + const parsed = parseSource("mcp-server-postgres"); + const config = buildServerConfig(parsed, { + args: ["--read-only", "--workspace", "team-a"], + }); + + assert.strictEqual(config.command, "npx"); + assert.deepStrictEqual(config.args, [ + "-y", + "mcp-server-postgres", + "--read-only", + "--workspace", + "team-a", + ]); +}); + +test("buildServerConfig - package includes env and args together", () => { + const parsed = parseSource("mcp-server-postgres"); + const config = buildServerConfig(parsed, { + env: { + DATABASE_URL: "postgres://localhost/my-db", + }, + args: ["--read-only"], + }); + + assert.strictEqual(config.command, "npx"); + assert.deepStrictEqual(config.args, [ + "-y", + "mcp-server-postgres", + "--read-only", + ]); + assert.deepStrictEqual(config.env, { + DATABASE_URL: "postgres://localhost/my-db", + }); +}); + // buildServerConfig tests - Command test("buildServerConfig - npx command", () => { const parsed = parseSource("npx -y @org/mcp-server"); @@ -217,17 +253,34 @@ test("buildServerConfig - command includes env when provided", () => { }); }); +test("buildServerConfig - command appends args when provided", () => { + const parsed = parseSource("node /path/to/server.js --port 3000"); + const config = buildServerConfig(parsed, { + args: ["--read-only"], + }); + + assert.strictEqual(config.command, "node"); + assert.deepStrictEqual(config.args, [ + "/path/to/server.js", + "--port", + "3000", + "--read-only", + ]); +}); + test("buildServerConfig - remote source ignores env", () => { const parsed = parseSource("https://mcp.example.com/api"); const config = buildServerConfig(parsed, { env: { API_KEY: "secret", }, + args: ["--ignored"], }); assert.strictEqual(config.type, "http"); assert.strictEqual(config.url, "https://mcp.example.com/api"); assert.strictEqual(config.env, undefined); + assert.strictEqual(config.args, undefined); }); // ============================================ diff --git a/tests/template.test.ts b/tests/template.test.ts new file mode 100644 index 0000000..df66f6c --- /dev/null +++ b/tests/template.test.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env tsx + +import assert from "node:assert"; +import { + findTemplateVars, + resolveTemplates, + resolveRecordTemplates, + resolveArrayTemplates, + hasTemplateVars, +} from "../src/template.js"; + +let passed = 0; +let failed = 0; +let testChain = Promise.resolve(); + +function test(name: string, fn: () => void | Promise) { + testChain = testChain + .then(fn) + .then(() => { + console.log(`✓ ${name}`); + passed++; + }) + .catch((err: unknown) => { + console.log(`✗ ${name}`); + console.error(` ${(err as Error).message}`); + failed++; + }); +} + +// findTemplateVars + +test("findTemplateVars returns empty array for plain string", () => { + assert.deepStrictEqual(findTemplateVars("hello world"), []); +}); + +test("findTemplateVars returns empty array for empty string", () => { + assert.deepStrictEqual(findTemplateVars(""), []); +}); + +test("findTemplateVars extracts single variable", () => { + assert.deepStrictEqual(findTemplateVars("${API_KEY}"), ["API_KEY"]); +}); + +test("findTemplateVars extracts variable embedded in text", () => { + assert.deepStrictEqual(findTemplateVars("Bearer ${TOKEN}"), ["TOKEN"]); +}); + +test("findTemplateVars extracts multiple variables", () => { + assert.deepStrictEqual(findTemplateVars("${HOST}:${PORT}/db"), [ + "HOST", + "PORT", + ]); +}); + +test("findTemplateVars ignores incomplete syntax", () => { + assert.deepStrictEqual(findTemplateVars("${UNCLOSED"), []); + assert.deepStrictEqual(findTemplateVars("$BARE_VAR"), []); + assert.deepStrictEqual(findTemplateVars("{NOT_TEMPLATE}"), []); +}); + +test("findTemplateVars is reentrant across calls", () => { + assert.deepStrictEqual(findTemplateVars("${A}"), ["A"]); + assert.deepStrictEqual(findTemplateVars("${B}"), ["B"]); + assert.deepStrictEqual(findTemplateVars("${C}${D}"), ["C", "D"]); +}); + +// resolveTemplates + +test("resolveTemplates returns value unchanged when no templates", async () => { + const result = await resolveTemplates("plain-value", async () => "unused"); + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.resolved, "plain-value"); +}); + +test("resolveTemplates substitutes single template", async () => { + const result = await resolveTemplates("${API_KEY}", async (name) => { + assert.strictEqual(name, "API_KEY"); + return "sk-123"; + }); + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.resolved, "sk-123"); +}); + +test("resolveTemplates substitutes template embedded in text", async () => { + const result = await resolveTemplates( + "Bearer ${TOKEN}", + async () => "my-token", + ); + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.resolved, "Bearer my-token"); +}); + +test("resolveTemplates substitutes multiple templates", async () => { + const answers = ["localhost", "5432"]; + let callIndex = 0; + const result = await resolveTemplates("${HOST}:${PORT}/db", async (name) => { + if (callIndex === 0) assert.strictEqual(name, "HOST"); + if (callIndex === 1) assert.strictEqual(name, "PORT"); + return answers[callIndex++]!; + }); + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.resolved, "localhost:5432/db"); +}); + +test("resolveTemplates returns cancelled when prompt is cancelled", async () => { + const result = await resolveTemplates("${KEY}", async () => Symbol("cancel")); + assert.strictEqual(result.cancelled, true); +}); + +test("resolveTemplates substitutes empty string for empty input", async () => { + const result = await resolveTemplates("prefix-${VAR}-suffix", async () => ""); + assert.strictEqual(result.cancelled, false); + assert.strictEqual(result.resolved, "prefix--suffix"); +}); + +test("resolveTemplates stops prompting after cancel on first of multiple", async () => { + let promptCount = 0; + const result = await resolveTemplates("${A}-${B}", async () => { + promptCount++; + return Symbol("cancel"); + }); + assert.strictEqual(result.cancelled, true); + assert.strictEqual(promptCount, 1); +}); + +// hasTemplateVars + +test("hasTemplateVars returns false for plain record values", () => { + assert.strictEqual(hasTemplateVars({ KEY: "value" }), false); +}); + +test("hasTemplateVars returns true when record has template", () => { + assert.strictEqual(hasTemplateVars({ KEY: "${VAR}" }), true); +}); + +test("hasTemplateVars returns false for plain array", () => { + assert.strictEqual(hasTemplateVars(["--read-only", "value"]), false); +}); + +test("hasTemplateVars returns true when array has template", () => { + assert.strictEqual(hasTemplateVars(["--db", "${DB_URL}"]), true); +}); + +test("hasTemplateVars returns false for empty inputs", () => { + assert.strictEqual(hasTemplateVars({}), false); + assert.strictEqual(hasTemplateVars([]), false); +}); + +// resolveRecordTemplates + +test("resolveRecordTemplates resolves templates in record values", async () => { + const result = await resolveRecordTemplates( + { API_KEY: "${TOKEN}", DB: "literal" }, + async (name) => { + assert.strictEqual(name, "TOKEN"); + return "resolved-token"; + }, + ); + assert.strictEqual(result.cancelled, false); + assert.deepStrictEqual(result.resolved, { + API_KEY: "resolved-token", + DB: "literal", + }); +}); + +test("resolveRecordTemplates returns cancelled on abort", async () => { + const result = await resolveRecordTemplates( + { A: "${X}", B: "${Y}" }, + async () => Symbol("cancel"), + ); + assert.strictEqual(result.cancelled, true); +}); + +test("resolveRecordTemplates passes through record with no templates", async () => { + const result = await resolveRecordTemplates( + { KEY: "plain" }, + async () => "unused", + ); + assert.strictEqual(result.cancelled, false); + assert.deepStrictEqual(result.resolved, { KEY: "plain" }); +}); + +// resolveArrayTemplates + +test("resolveArrayTemplates resolves templates in array values", async () => { + const result = await resolveArrayTemplates( + ["${CONN}", "--read-only"], + async (name) => { + assert.strictEqual(name, "CONN"); + return "postgres://localhost/db"; + }, + ); + assert.strictEqual(result.cancelled, false); + assert.deepStrictEqual(result.resolved, [ + "postgres://localhost/db", + "--read-only", + ]); +}); + +test("resolveArrayTemplates returns cancelled on abort", async () => { + const result = await resolveArrayTemplates(["${A}", "${B}"], async () => + Symbol("cancel"), + ); + assert.strictEqual(result.cancelled, true); +}); + +test("resolveArrayTemplates passes through array with no templates", async () => { + const result = await resolveArrayTemplates( + ["--flag", "value"], + async () => "unused", + ); + assert.strictEqual(result.cancelled, false); + assert.deepStrictEqual(result.resolved, ["--flag", "value"]); +}); + +testChain.then(() => { + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +});