diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 16c3171852..7834958112 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -69,6 +69,8 @@ import type { TraceOptions } from "./trace.js"; import type { CancellationOptions } from "./cancellation.js"; import { genaiscriptDebug } from "./debug.js"; import { YAMLTryParse } from "./yaml.js"; +import { JSON5TryParse } from "./json5.js"; +import type { PromptArgs, PromptScript } from "./types.js"; const dbg = genaiscriptDebug("config:env"); /** @@ -122,6 +124,50 @@ export function findEnvVar( return undefined; } +/** + * Parses default script metadata from GENAISCRIPT_DEFAULT_SCRIPT_META environment variable. + * The environment variable should contain a JSON payload of PromptScript metadata. + * This metadata gets merged last into the main script metadata object. + * + * @param env - The environment variables as key-value pairs. + * @returns A PromptArgs object containing the parsed metadata, or undefined if no valid metadata found. + */ +export function parseDefaultMetaFromEnv(env: Record): Partial | undefined { + const envValue = env.GENAISCRIPT_DEFAULT_SCRIPT_META; + if (!envValue) { + dbg("GENAISCRIPT_DEFAULT_SCRIPT_META not found in environment variables"); + return undefined; + } + + dbg(`found GENAISCRIPT_DEFAULT_SCRIPT_META: ${envValue}`); + + try { + const parsed = JSON5TryParse(envValue); + if (!parsed || typeof parsed !== "object") { + dbg("GENAISCRIPT_DEFAULT_SCRIPT_META could not be parsed as valid JSON object"); + return undefined; + } + + dbg(`parsed GENAISCRIPT_DEFAULT_SCRIPT_META: %O`, parsed); + + // Filter to only include valid PromptArgs fields (exclude text, id, jsSource, defTools, resolvedSystem) + const excludedFields = new Set(['text', 'id', 'jsSource', 'defTools', 'resolvedSystem']); + const filtered: Partial = {}; + + for (const [key, value] of Object.entries(parsed)) { + if (!excludedFields.has(key)) { + (filtered as any)[key] = value; + } + } + + dbg(`filtered GENAISCRIPT_DEFAULT_SCRIPT_META: %O`, filtered); + return filtered; + } catch (error) { + dbg(`failed to parse GENAISCRIPT_DEFAULT_SCRIPT_META: ${error}`); + return undefined; + } +} + /** * Parses default configuration values from the provided environment variables. * diff --git a/packages/core/src/template.ts b/packages/core/src/template.ts index a3b174ddf5..98c763f846 100644 --- a/packages/core/src/template.ts +++ b/packages/core/src/template.ts @@ -15,6 +15,7 @@ import { deleteUndefinedValues } from "./cleaners.js"; import { markdownScriptParse } from "./markdownscript.js"; import { readJSON } from "./fs.js"; import { frontmatterTryParse } from "./frontmatter.js"; +import { parseDefaultMetaFromEnv } from "./env.js"; import type { PromptArgs, PromptScript, @@ -241,5 +242,15 @@ export async function parsePromptScript(filename: string, content: string) { }; } + // Parse and merge default metadata from environment variables (last to take priority) + const envDefaults = parseDefaultMetaFromEnv(process.env); + if (envDefaults?.metadata) { + // Only merge metadata field from environment defaults + script.metadata = metadataValidate({ + ...(script.metadata || {}), + ...(envDefaults.metadata || {}), // env metadata takes precedence + }); + } + return script; } diff --git a/packages/core/test/env.test.ts b/packages/core/test/env.test.ts index 9c63bdc640..d5c0a9d065 100644 --- a/packages/core/test/env.test.ts +++ b/packages/core/test/env.test.ts @@ -1,5 +1,5 @@ import { describe, test, assert } from "vitest"; -import { parseAllowedDomains } from "../src/env.js"; +import { parseAllowedDomains, parseDefaultMetaFromEnv } from "../src/env.js"; describe("env", () => { describe("parseAllowedDomains", () => { @@ -53,4 +53,83 @@ describe("env", () => { assert.deepStrictEqual(result, ["github.com"]); }); }); + + describe("parseDefaultMetaFromEnv", () => { + test("returns undefined when GENAISCRIPT_DEFAULT_SCRIPT_META not set", () => { + const result = parseDefaultMetaFromEnv({}); + assert.strictEqual(result, undefined); + }); + + test("parses valid JSON metadata", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: '{"temperature": 0.5, "model": "gpt-4", "title": "Default Title"}' + }; + const result = parseDefaultMetaFromEnv(env); + assert.deepStrictEqual(result, { + temperature: 0.5, + model: "gpt-4", + title: "Default Title" + }); + }); + + test("parses valid JSON5 metadata", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: '{temperature: 0.5, model: "gpt-4", unlisted: true}' + }; + const result = parseDefaultMetaFromEnv(env); + assert.deepStrictEqual(result, { + temperature: 0.5, + model: "gpt-4", + unlisted: true + }); + }); + + test("handles metadata with nested objects", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: '{"metadata": {"key1": "value1", "key2": "value2"}, "vars": {"var1": "val1"}}' + }; + const result = parseDefaultMetaFromEnv(env); + assert.deepStrictEqual(result, { + metadata: { + key1: "value1", + key2: "value2" + }, + vars: { + var1: "val1" + } + }); + }); + + test("returns undefined for invalid JSON", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: 'invalid json {' + }; + const result = parseDefaultMetaFromEnv(env); + assert.strictEqual(result, undefined); + }); + + test("returns undefined for non-object values", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: '"just a string"' + }; + const result = parseDefaultMetaFromEnv(env); + assert.strictEqual(result, undefined); + }); + + test("returns undefined for null values", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: 'null' + }; + const result = parseDefaultMetaFromEnv(env); + assert.strictEqual(result, undefined); + }); + + test("handles empty object", () => { + const env = { + GENAISCRIPT_DEFAULT_SCRIPT_META: '{}' + }; + const result = parseDefaultMetaFromEnv(env); + assert.deepStrictEqual(result, {}); + }); + }); }); \ No newline at end of file diff --git a/packages/core/test/template.test.ts b/packages/core/test/template.test.ts index 2fb581dfae..4345378be2 100644 --- a/packages/core/test/template.test.ts +++ b/packages/core/test/template.test.ts @@ -1,4 +1,4 @@ -import { describe, test, assert } from "vitest" +import { describe, test, assert, vi, beforeEach, afterEach } from "vitest" import { parsePromptScript } from "../src/template.js" describe("template.ts - frontmatter parameters", () => { @@ -119,4 +119,109 @@ Configuration test with {{items}} and {{config}}.` maximum: 1 }) }) +}) + +describe("template.ts - environment variable default metadata", () => { + let originalEnv: any + + beforeEach(() => { + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.env = originalEnv + }) + + test("should merge environment default metadata into script metadata field", async () => { + process.env.GENAISCRIPT_DEFAULT_SCRIPT_META = '{"metadata": {"env_key": "env_value", "shared_key": "env_shared"}}' + + const content = `script({ + title: "Test Script", + description: "A test script", + metadata: { + script_key: "script_value", + shared_key: "script_shared" + } + }) + + Hello world!` + + const script = await parsePromptScript("test.genai.mts", content) + + assert.strictEqual(script.title, "Test Script") + assert.strictEqual(script.description, "A test script") + assert.ok(script.metadata) + assert.strictEqual(script.metadata.env_key, "env_value") + assert.strictEqual(script.metadata.script_key, "script_value") + // Environment metadata should take precedence for shared keys + assert.strictEqual(script.metadata.shared_key, "env_shared") + }) + + test("should handle environment metadata without existing script metadata", async () => { + process.env.GENAISCRIPT_DEFAULT_SCRIPT_META = '{"metadata": {"env_key": "env_value"}}' + + const content = `script({ + title: "Test Script" + }) + + Hello world!` + + const script = await parsePromptScript("test.genai.mts", content) + + assert.strictEqual(script.title, "Test Script") + assert.ok(script.metadata) + assert.strictEqual(script.metadata.env_key, "env_value") + }) + + + test("should work without environment variable set", async () => { + delete process.env.GENAISCRIPT_DEFAULT_SCRIPT_META + + const content = `script({ + title: "Test Script", + metadata: { + original: "value" + } + }) + + Hello world!` + + const script = await parsePromptScript("test.genai.mts", content) + + assert.strictEqual(script.title, "Test Script") + assert.ok(script.metadata) + assert.strictEqual(script.metadata.original, "value") + }) + + test("should handle invalid JSON in environment variable gracefully", async () => { + process.env.GENAISCRIPT_DEFAULT_SCRIPT_META = 'invalid json {' + + const content = `script({ + title: "Test Script" + }) + + Hello world!` + + const script = await parsePromptScript("test.genai.mts", content) + + assert.strictEqual(script.title, "Test Script") + // Should not throw an error, just ignore the invalid env var + }) + + test("should handle environment metadata with nested objects", async () => { + process.env.GENAISCRIPT_DEFAULT_SCRIPT_META = '{"metadata": {"nested": {"key": "value"}, "simple": "data"}}' + + const content = `script({ + title: "Test Script" + }) + + Hello world!` + + const script = await parsePromptScript("test.genai.mts", content) + + assert.strictEqual(script.title, "Test Script") + assert.ok(script.metadata) + assert.deepEqual(script.metadata.nested, { key: "value" }) + assert.strictEqual(script.metadata.simple, "data") + }) }) \ No newline at end of file