@domesystems/templates 0.3.8 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domesystems/templates",
3
- "version": "0.3.8",
3
+ "version": "0.4.1",
4
4
  "description": "Pull, publish, and run versioned Dome templates.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
+ import { parse as parseYAML } from "yaml";
4
5
  import { readManifest, readPublishManifest } from "./manifest.mjs";
5
6
  import { generateDomeTerraform } from "./generate.mjs";
6
7
  import { imageForRuntime, renderCompose } from "./runtime.mjs";
@@ -10,15 +11,15 @@ import { DEFAULT_REGISTRY, clearSelectedTarget, deleteProfile, directRegistry, d
10
11
 
11
12
  function ansi(code, value, options) { return options.color && process.stdout.isTTY ? `\u001B[${code}m${value}\u001B[0m` : value; }
12
13
  function line(kind, value, options) { const styles = { info: ["•", "36"], success: ["✓", "32"], warning: ["!", "33"], error: ["✗", "31"] }; const [mark, code] = styles[kind]; return `${ansi(code, mark, options)} ${value}`; }
13
- function usage() { return "Usage:\n templates login [--server <Dome application URL>|--registry <Registry URL>] [--github]\n templates logout [--server <Dome application URL>|--registry <Registry URL>]\n templates doctor <pull|up|publish> [package] [--dir <directory>] [--server <URL>|--registry <URL>] [--json]\n templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>|--registry <URL>]\n templates generate [--dir <directory>] [--output <file>] [--force]\n templates up [--dir <directory>] [--port <host-port>] [--server <URL>|--registry <URL>] [--import-job <job-id>] [--skip-provision|--provision] [--no-start] [--detach] [--debug] [--verbose]\n templates publish [--dir <directory>] [--dry-run] [--server <Dome application URL>|--registry <Registry URL>]\n"; }
14
+ function usage() { return "Usage:\n templates login [--server <Dome application URL>|--registry <Registry URL>] [--github]\n templates logout [--server <Dome application URL>|--registry <Registry URL>]\n templates doctor <pull|up|publish> [package] [--dir <directory>] [--server <URL>|--registry <URL>] [--json]\n templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>|--registry <URL>]\n templates generate [--dir <directory>] [--output <file>] [--force]\n templates up [--dir <directory>] [--agent <agent-name>] [--port <host-port>] [--server <URL>|--registry <URL>] [--import-job <job-id>] [--skip-provision|--provision] [--no-start] [--detach] [--debug] [--verbose]\n templates publish [--dir <directory>] [--dry-run] [--server <Dome application URL>|--registry <Registry URL>]\n"; }
14
15
  function parse(argv) {
15
16
  if (!argv.length || argv.includes("--help") || argv.includes("-h")) return { help: true };
16
17
  const [command, ...rest] = argv;
17
- const options = { server: undefined, directory: undefined, output: "dome.tf", force: false, port: 3000, dryRun: false, json: false, github: false, importJob: undefined, provision: true, forceProvision: false, start: true, detach: false, debug: false, verbose: false, color: !process.env.NO_COLOR };
18
+ const options = { server: undefined, directory: undefined, output: "dome.tf", force: false, port: 3000, dryRun: false, json: false, github: false, importJob: undefined, agent: undefined, provision: true, forceProvision: false, start: true, detach: false, debug: false, verbose: false, color: !process.env.NO_COLOR };
18
19
  const values = [];
19
20
  for (let i = 0; i < rest.length; i += 1) {
20
21
  const arg = rest[i];
21
- if (arg === "--server" || arg === "--registry" || arg === "--dir" || arg === "--output" || arg === "--port" || arg === "--color" || arg === "--import-job") { const value = rest[++i]; if (!value) throw new Error(`${arg} requires a value.`); if (arg === "--server") options.server = normalizeServer(value); else if (arg === "--registry") options.registry = normalizeServer(value); else if (arg === "--dir") options.directory = value; else if (arg === "--output") options.output = value; else if (arg === "--import-job") options.importJob = value; else if (arg === "--color") { if (!["auto", "always", "never"].includes(value)) throw new Error("--color must be auto, always, or never."); options.color = value === "always" || (value === "auto" && !process.env.NO_COLOR); } else { options.port = Number(value); if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("--port requires an integer from 1 to 65535."); } }
22
+ if (arg === "--server" || arg === "--registry" || arg === "--dir" || arg === "--output" || arg === "--port" || arg === "--color" || arg === "--import-job" || arg === "--agent") { const value = rest[++i]; if (!value) throw new Error(`${arg} requires a value.`); if (arg === "--server") options.server = normalizeServer(value); else if (arg === "--registry") options.registry = normalizeServer(value); else if (arg === "--dir") options.directory = value; else if (arg === "--output") options.output = value; else if (arg === "--import-job") options.importJob = value; else if (arg === "--agent") options.agent = value; else if (arg === "--color") { if (!["auto", "always", "never"].includes(value)) throw new Error("--color must be auto, always, or never."); options.color = value === "always" || (value === "auto" && !process.env.NO_COLOR); } else { options.port = Number(value); if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("--port requires an integer from 1 to 65535."); } }
22
23
  else if (arg === "--github") options.github = true; else if (arg === "--dry-run") options.dryRun = true; else if (arg === "--json") options.json = true; else if (arg === "--force") options.force = true; else if (arg === "--skip-provision") options.provision = false; else if (arg === "--provision") options.forceProvision = true; else if (arg === "--no-start") options.start = false; else if (arg === "--detach") options.detach = true; else if (arg === "--debug") options.debug = true; else if (arg === "--verbose") options.verbose = true; else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); else values.push(arg);
23
24
  }
24
25
  if (options.server && options.registry) throw new Error("Use either --server or --registry, not both.");
@@ -59,6 +60,21 @@ function generate(options, output) {
59
60
  const file = generateDomeTerraform(target, { output: options.output, force: options.force, manifest });
60
61
  output(line("success", `Generated ${file}`, options));
61
62
  }
63
+ function runtimeFor(directory, manifest, selectedAgent) {
64
+ const candidates = selectedAgent ? manifest.agents.filter((agent) => agent.name === selectedAgent) : manifest.agents.filter((agent) => agent.type === "external");
65
+ if (candidates.length !== 1) throw new Error(`templates up requires exactly one external agent; found ${candidates.length}. Pass --agent <name> to select one.`);
66
+ const agent = candidates[0];
67
+ if (agent.type !== "external") throw new Error(`templates up cannot start managed agent ${agent.name}; Dome runs its loop.`);
68
+ const configPath = path.join("agents", agent.name);
69
+ const configFile = path.join(directory, configPath, "agent.yaml");
70
+ if (!fs.existsSync(configFile)) throw new Error(`External agent ${agent.name} needs ${configPath}/agent.yaml.`);
71
+ const runner = parseYAML(fs.readFileSync(configFile, "utf8"))?.runner;
72
+ if (!runner?.pattern || !runner?.language) throw new Error(`${configPath}/agent.yaml needs runner.pattern and runner.language.`);
73
+ const keys = agent.keys ?? [];
74
+ const key = keys.find((candidate) => candidate.name === "local") ?? (keys.length === 1 ? keys[0] : null);
75
+ if (!key) throw new Error(`External agent ${agent.name} needs exactly one key or a key named local for templates up.`);
76
+ return { ...agent, runtime: runner, configPath, localKey: key.name };
77
+ }
62
78
  function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, stdio: "inherit" }); if (result.error || result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed.`); }
63
79
  export function composeCommands({ detach = false } = {}) {
64
80
  const prefix = ["compose", "-f", ".dome-compose.yaml"];
@@ -94,8 +110,27 @@ function configured(target) { const env = path.join(target, ".env"); return fs.e
94
110
  export function importMadeNoChanges(job) { const summary = job?.plan_summary ?? job?.planSummary; if (!summary) return false; return ["create_count", "update_count", "import_count"].every((key) => Number(summary[key] ?? summary[key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())] ?? 0) === 0); }
95
111
  export function importHasPendingOutputs(job) { return (job?.outputs_state ?? job?.outputsState) === "pending"; }
96
112
  export function importCreatedResources(job) { const summary = job?.plan_summary ?? job?.planSummary; return Number(summary?.create_count ?? summary?.createCount ?? 0) > 0; }
97
- async function configureRuntime(target, jobID, options, output) { const gateways = await runJSON("dome", ["gateways", "list", "--json"], target); const gatewayURL = gateways?.data?.gateways?.find((candidate) => candidate.is_default)?.endpoints?.gateway_url; if (!gatewayURL) throw new Error("The import did not configure a default Gateway for the runtime."); const revealed = await runJSON("dome", ["import", "outputs", jobID, "--json"], target); const tokens = Object.entries(revealed?.data?.outputs ?? {}).filter(([key, value]) => key.endsWith("_token") && typeof value === "string"); if (tokens.length !== 1) throw new Error("The import did not produce exactly one runtime agent token."); writeRuntimeEnvironment(target, { DOME_TOKEN: tokens[0][1], DOME_GATEWAY_URL: gatewayURL }); output(line("success", "Configured the runtime with the imported agent credentials.", options)); }
98
- async function up(options, output) { const target = path.resolve(process.cwd(), options.directory ?? "."); const manifest = readManifest(target); const needsCredentials = !configured(target); output(line("info", `Checking ${target}`, options)); fs.writeFileSync(path.join(target, ".dome-compose.yaml"), renderCompose(manifest.app.runtime, options.port, { debug: options.debug })); if (options.importJob) { output(line("info", "Saving credentials from the selected Dome import", options)); await configureRuntime(target, options.importJob, options, output); } else if (options.provision && (needsCredentials || options.forceProvision)) { output(line("info", "Setting up Dome resources", options)); let imported; try { imported = await runJSON("dome", ["import", "dome.tf", "--json"], target); } catch (caught) { const dashboardURL = importDashboardURL(target); const details = dashboardURL ? ` View the import details: ${dashboardURL}` : ""; throw new Error(`${caught.message}${details}`); } const job = imported?.data?.job; if (!job?.id) throw new Error("Dome import did not return a job ID."); if (importCreatedResources(job) && importHasPendingOutputs(job)) await configureRuntime(target, job.id, options, output); else if (needsCredentials) throw new Error(`Dome matched existing resources and did not create a new runtime token. Recover a pending import with templates up --dir ${options.directory ?? "."} --import-job <job-id>, or rotate the runtime key and set DOME_TOKEN and DOME_GATEWAY_URL in .env.`); else output(line("success", "Dome resources are already up to date; keeping the existing runtime credentials.", options)); } if (options.start) { const compose = composeCommands(options); output(line("info", "Refreshing runtime image", options)); run("docker", compose.pull, target); output(line("info", `Starting app at http://localhost:${options.port}`, options)); run("docker", compose.up, target); } }
113
+ async function configureRuntime(target, jobID, agentName, keyName, options, output) { const gateways = await runJSON("dome", ["gateways", "list", "--json"], target); const gatewayURL = gateways?.data?.gateways?.find((candidate) => candidate.is_default)?.endpoints?.gateway_url; if (!gatewayURL) throw new Error("The import did not configure a default Gateway for the runtime."); const revealed = await runJSON("dome", ["import", "outputs", jobID, "--json"], target); const tokenKey = `${agentName.replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]/, "_")}_${keyName.replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]/, "_")}_token`; const token = revealed?.data?.outputs?.[tokenKey]; if (typeof token !== "string") throw new Error(`The import did not produce the ${keyName} key for ${agentName}.`); writeRuntimeEnvironment(target, { DOME_TOKEN: token, DOME_GATEWAY_URL: gatewayURL }); output(line("success", `Configured the ${agentName} runtime with its imported credentials.`, options)); }
114
+ async function up(options, output) {
115
+ const target = path.resolve(process.cwd(), options.directory ?? ".");
116
+ const manifest = readManifest(target);
117
+ const runtimeAgent = runtimeFor(target, manifest, options.agent);
118
+ const needsCredentials = !configured(target);
119
+ output(line("info", `Checking ${target}`, options));
120
+ fs.writeFileSync(path.join(target, ".dome-compose.yaml"), renderCompose(runtimeAgent.runtime, options.port, { debug: options.debug, configPath: runtimeAgent.configPath }));
121
+ if (options.importJob) await configureRuntime(target, options.importJob, runtimeAgent.name, runtimeAgent.localKey, options, output);
122
+ else if (options.provision && (needsCredentials || options.forceProvision)) {
123
+ output(line("info", "Setting up Dome resources", options));
124
+ let imported;
125
+ try { imported = await runJSON("dome", ["import", "dome.tf", "--json"], target); }
126
+ catch (caught) { const dashboardURL = importDashboardURL(target); throw new Error(`${caught.message}${dashboardURL ? ` View the import details: ${dashboardURL}` : ""}`); }
127
+ const job = imported?.data?.job;
128
+ if (!job?.id) throw new Error("Dome import did not return a job ID.");
129
+ if (importCreatedResources(job) && importHasPendingOutputs(job)) await configureRuntime(target, job.id, runtimeAgent.name, runtimeAgent.localKey, options, output);
130
+ else if (needsCredentials) throw new Error(`Dome matched existing resources and did not create a new runtime token. Recover a pending import with templates up --dir ${options.directory ?? "."} --import-job <job-id>, or rotate the local agent key and set DOME_TOKEN and DOME_GATEWAY_URL in .env.`);
131
+ }
132
+ if (options.start) { const compose = composeCommands(options); run("docker", compose.pull, target); run("docker", compose.up, target); }
133
+ }
99
134
  async function publish(options, output, fetchImplementation) { const target = path.resolve(process.cwd(), options.directory ?? "."); const manifest = readPublishManifest(target); const source = publishSource(target); const name = manifest.metadata.name; const [scope, slug] = name.split("/"); const archive = options.dryRun ? null : createArchive(source); output(line("info", `Checking ${name}@${manifest.metadata.version}`, options)); const payload = { name, version: manifest.metadata.version, title: manifest.metadata.title, summary: manifest.metadata.summary ?? "", sha256: archive?.sha256, size_bytes: archive?.buffer.length, source: { repository: manifest.metadata.source.repository, path: manifest.metadata.source.path, commit: source.commit, remote: source.remote, files: source.files } };
100
135
  const remote = remoteTarget(options); let registry; let attempt;
101
136
  try { ({ target: registry, body: attempt } = await registryRequest(remote, "/v1/publish-attempts", { method: "POST", body: { ...payload, dry_run: options.dryRun }, authenticated: true, fetchImplementation })); } catch (caught) {
package/src/generate.mjs CHANGED
@@ -8,213 +8,143 @@ const label = (value) => {
8
8
  };
9
9
  const q = (value) => JSON.stringify(String(value));
10
10
  const attr = (name, value) => `${name.padEnd(19)} = ${value}`;
11
- const block = (type, name, body) => `resource ${q(type)} ${q(name)} {\n${body.map((line) => ` ${line}`).join("\n")}\n}`;
12
- const variable = (name) => `variable ${q(name)} {\n type = string\n sensitive = true\n}`;
13
- const nameFor = (prefix, value) => `${prefix}_${label(value)}`;
11
+ const block = (type, name, body) => `resource ${q(type)} ${q(name)} {\n${body.map((line) => line === "" ? "" : ` ${line}`).join("\n")}\n}`;
12
+ const required = (object, key, message) => { if (object?.[key] === undefined || object[key] === "") throw new Error(message); return object[key]; };
13
+ const expression = (value) => typeof value === "string" && /^(var|dome_[a-z_]+\.)[A-Za-z0-9_.]+$/.test(value) ? value : q(value);
14
14
 
15
- function required(object, key, message) {
16
- if (!object?.[key]) throw new Error(message);
17
- return object[key];
18
- }
19
-
20
- function secretVariable(secret) {
21
- return secret?.variable ? label(secret.variable) : null;
22
- }
23
-
24
- function connectionBlocks(connection, gatewayLabel, variables) {
25
- const connectionLabel = label(required(connection, "name", "Every catalog connection needs a name."));
26
- const auth = connection.auth ?? { method: "none" };
27
- const lines = [
28
- attr("workspace_id", "var.workspace_id"),
29
- attr("name", q(connection.name)),
30
- attr("protocol", q(required(connection, "protocol", `Connection ${connection.name} needs a protocol.`))),
31
- attr("url", q(required(connection, "url", `Connection ${connection.name} needs a URL.`))),
32
- attr("auth_method", q(auth.method)),
33
- ];
34
- if (auth.credential) lines.push(attr("credential_type", q(auth.credential)));
35
- if (auth.oauth) {
36
- const oauth = Object.entries(auth.oauth).map(([key, value]) => {
37
- const destination = key === "scopes" ? "default_scopes" : key;
38
- return `${destination} = ${Array.isArray(value) ? `[${value.map(q).join(", ")}]` : q(value)}`;
39
- });
40
- lines.push(`oauth = { ${oauth.join(", ")} }`);
41
- }
42
- const secret = auth.secret;
43
- if (secretVariable(secret)) {
44
- const key = auth.header ?? "Authorization";
45
- variables.add(secretVariable(secret));
46
- lines.push(attr("secret_values", `{ ${q(key)} = var.${secretVariable(secret)} }`));
47
- }
48
- const output = [block("dome_mcp_connection", connectionLabel, lines)];
49
- for (const tool of [...(connection.permit ?? []), ...(connection.forbid ?? [])]) {
50
- output.push(block("dome_gateway_tool", nameFor(connectionLabel, tool), [
51
- attr("workspace_id", "var.workspace_id"),
52
- attr("gateway", `dome_gateway.${gatewayLabel}.name`),
53
- attr("mcp_connection", `dome_mcp_connection.${connectionLabel}.name`),
54
- attr("tool", q(tool)),
55
- ]));
56
- }
57
- return output;
58
- }
59
-
60
- function modelBlocks(model, pool, gatewayLabel, variables) {
61
- const modelLabel = label(required(model, "name", "catalog.model needs a name."));
62
- const poolLabel = label(required(pool, "name", "catalog.pool needs a name."));
63
- const secret = model.secret;
64
- const modelLines = [
65
- attr("workspace_id", "var.workspace_id"),
66
- attr("name", q(model.name)),
67
- attr("provider_id", q(required(model, "provider", "catalog.model needs a provider."))),
68
- attr("provider_config", `jsonencode({ model = ${q(required(model, "model_id", "catalog.model needs a model_id."))} })`),
69
- ];
70
- if (secretVariable(secret)) {
71
- const variableName = secretVariable(secret);
72
- // Dome shared API-key credentials are stored against the provider's
73
- // managed header. Its default is x-api-key; Authorization is not a
74
- // substitute and is rejected by the Management API.
75
- const secretHeader = model.secret_header ?? "x-api-key";
76
- variables.add(variableName);
77
- modelLines.push(
78
- attr("auth_method", q("api-key")),
79
- attr("credential_type", q("shared")),
80
- attr("managed_header_name", q(secretHeader)),
81
- attr("secret_values", `{ ${q(secretHeader)} = var.${variableName} }`),
82
- );
83
- }
84
- return [
85
- block("dome_llm_connection", modelLabel, modelLines),
86
- block("dome_llm_pool", poolLabel, [
87
- attr("workspace_id", "var.workspace_id"),
88
- attr("name", q(pool.name)),
89
- // Generated templates must coexist with any pool already installed in
90
- // the target workspace. A workspace permits only one default pool, so
91
- // never make a reusable template's pool the default during provisioning.
92
- attr("is_default", "false"),
93
- ]),
94
- block("dome_llm_pool_member", modelLabel, [
95
- attr("workspace_id", "var.workspace_id"),
96
- attr("pool", `dome_llm_pool.${poolLabel}.name`),
97
- attr("llm_connection", `dome_llm_connection.${modelLabel}.name`),
98
- ]),
99
- block("dome_gateway_llm_pool", poolLabel, [
100
- attr("workspace_id", "var.workspace_id"),
101
- attr("gateway", `dome_gateway.${gatewayLabel}.name`),
102
- attr("llm_pool", `dome_llm_pool.${poolLabel}.name`),
103
- ]),
104
- ];
15
+ function variableBlocks(variables) {
16
+ return Object.entries(variables).map(([name, definition]) => {
17
+ const value = definition && typeof definition === "object" ? definition : {};
18
+ return `variable ${q(name)} {\n type = string${value.sensitive ? "\n sensitive = true" : ""}\n}`;
19
+ });
105
20
  }
106
21
 
107
- function rulesBlock(root, rules, agentLabel) {
108
- if (!rules?.file) return [];
109
- const file = path.resolve(root, rules.file);
110
- if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`Rules file not found: ${rules.file}.`);
111
- const filename = path.basename(rules.file);
112
- const contents = fs.readFileSync(file, "utf8").trimEnd();
113
- return [block("dome_agent_rules_bundle", agentLabel, [
114
- attr("workspace_id", "var.workspace_id"),
115
- attr("agent", `dome_agent.${agentLabel}.name`),
116
- "files = {",
117
- ` ${q(filename)} = <<-CEDAR`,
118
- ...contents.split("\n").map((line) => ` ${line}`),
119
- " CEDAR",
120
- "}",
121
- ])];
22
+ function rulesBlock(agent, resourceType, agentLabel) {
23
+ const files = agent.rules?.files;
24
+ if (!files || !Object.keys(files).length) return [];
25
+ const body = [attr("workspace_id", "var.workspace_id"), attr("agent", `${resourceType}.${agentLabel}.name`), "files = {"];
26
+ for (const [name, file] of Object.entries(files)) body.push(` ${q(name)} = <<-CEDAR`, ...file.content.split("\n").map((line) => line.trimEnd() ? ` ${line.trimEnd()}` : ""), " CEDAR");
27
+ body.push("}");
28
+ return [block("dome_agent_rules_bundle", agentLabel, body)];
122
29
  }
123
30
 
124
- function guardBlocks(root, guards, refs) {
125
- const filters = guards?.filters ?? [];
31
+ function filterBlocks(filters) {
126
32
  const blocks = [];
127
33
  const bindings = new Map();
128
34
  for (const filter of filters) {
129
- const filterLabel = label(required(filter, "name", "Every Guard filter needs a name."));
130
- let config;
131
- if (filter.redact_ssn) config = "jsonencode({ text = { components = [{ action = \"FILTER_ACTION_REDACT\", matchers = [{ ssn = {} }] }] } })";
132
- else if (filter.config) {
133
- const file = path.resolve(root, filter.config);
134
- if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) throw new Error(`Guard config not found: ${filter.config}.`);
135
- config = `jsonencode(jsondecode(<<-JSON\n${fs.readFileSync(file, "utf8").trimEnd()}\nJSON\n))`;
136
- } else throw new Error(`Guard ${filter.name} needs config or redact_ssn.`);
137
- blocks.push(block("dome_filter", filterLabel, [attr("workspace_id", "var.workspace_id"), attr("name", q(filter.name)), attr("description", q(filter.description ?? filter.name)), attr("config", config)]));
138
- const binding = required(filter, "bind", `Guard ${filter.name} needs a bind target.`);
139
- const kind = binding.model ? "model" : binding.tool ? "tool" : null;
140
- if (!kind) throw new Error(`Guard ${filter.name} must bind to a model or tool.`);
141
- const target = kind === "model" ? label(binding.model) : label(binding.tool);
142
- const key = `${kind}:${target}:${binding.direction ?? "request"}`;
143
- bindings.set(key, [...(bindings.get(key) ?? []), filterLabel]);
35
+ const filterLabel = label(required(filter, "name", "Every filter needs a name."));
36
+ if (!filter.config || typeof filter.config !== "object") throw new Error(`Filter ${filter.name} needs config.`);
37
+ blocks.push(block("dome_filter", filterLabel, [attr("workspace_id", "var.workspace_id"), attr("name", q(filter.name)), attr("description", q(filter.description ?? filter.name)), attr("config", `jsonencode(${JSON.stringify(filter.config)})`)]));
38
+ for (const binding of filter.bindings ?? []) {
39
+ const kind = binding.llm_connection ? "llm" : binding.tool_connection ? "tool" : null;
40
+ if (!kind) throw new Error(`Filter ${filter.name} bindings must name llm_connection or tool_connection.`);
41
+ const connection = binding.llm_connection ?? binding.tool_connection;
42
+ const direction = binding.direction ?? "request";
43
+ const key = `${kind}:${connection}:${direction}`;
44
+ bindings.set(key, [...(bindings.get(key) ?? []), filterLabel]);
45
+ }
144
46
  }
145
- for (const [key, filterLabels] of bindings) {
146
- const [kind, target, direction] = key.split(":");
147
- const resource = kind === "model" ? "dome_llm_connection_filters" : "dome_mcp_connection_filters";
148
- const attribute = kind === "model" ? "llm_connection" : "mcp_connection";
149
- blocks.push(block(resource, `${target}_${direction}`, [
150
- attr("workspace_id", "var.workspace_id"),
151
- attr(attribute, `${kind === "model" ? "dome_llm_connection" : "dome_mcp_connection"}.${target}.name`),
152
- attr("direction", q(direction)),
153
- attr("filters", `[${filterLabels.map((item) => `dome_filter.${item}.name`).join(", ")}]`),
154
- ]));
47
+ for (const [key, names] of bindings) {
48
+ const [kind, connection, direction] = key.split(":");
49
+ const resource = kind === "llm" ? "dome_llm_connection_filters" : "dome_mcp_connection_filters";
50
+ const attribute = kind === "llm" ? "llm_connection" : "mcp_connection";
51
+ const target = kind === "llm" ? "dome_llm_connection" : "dome_mcp_connection";
52
+ blocks.push(block(resource, `${label(connection)}_${direction}`, [attr("workspace_id", "var.workspace_id"), attr(attribute, `${target}.${label(connection)}.name`), attr("direction", q(direction)), attr("filters", `[${names.map((name) => `dome_filter.${name}.name`).join(", ")}]`)]));
155
53
  }
156
54
  return blocks;
157
55
  }
158
56
 
159
- function quotaBlocks(quotas, refs) {
160
- return (quotas ?? []).map((quota) => {
161
- const subject = required(quota, "subject", "Every quota needs a subject.");
162
- const target = subject === "agent" ? quota.agent : subject === "model" ? quota.model : quota.connection ?? String(quota.tool ?? "").split("/")[0];
163
- if (!target) throw new Error(`Quota ${quota.name ?? ""} needs a target.`);
164
- const resource = subject === "agent" ? "dome_agent" : subject === "model" ? "dome_llm_connection" : "dome_mcp_connection";
165
- const type = subject === "agent" ? "agent" : subject === "model" ? "model" : "tool_connection";
166
- const amount = quota.unit === "dome_usd" ? Math.round(Number(quota.limit) * 1_000_000) : Number(quota.limit);
167
- if (!Number.isFinite(amount)) throw new Error(`Quota ${quota.name ?? ""} needs a numeric limit.`);
168
- return block("dome_quota", label(quota.name ?? `${subject}_${target}`), [
169
- attr("workspace_id", "var.workspace_id"),
170
- attr("dimension", q(type === "tool_connection" ? "tool" : "llm")),
171
- attr("unit", q(required(quota, "unit", "Every quota needs a unit."))),
172
- attr("subject_type", q(type)),
173
- attr("subject", `${resource}.${label(target)}.name`),
174
- attr("scope", q(quota.per_caller ? "per_caller" : "total")),
175
- attr("window", q(required(quota, "window", "Every quota needs a window."))),
176
- attr("limit_amount", String(amount)),
177
- attr("name", q(required(quota, "name", "Every quota needs a name."))),
178
- ]);
57
+ function quotaBlocks(quotas, agents) {
58
+ return quotas.map((quota) => {
59
+ const subjectType = required(quota, "subject_type", "Every quota needs subject_type.");
60
+ const subject = required(quota, "subject", "Every quota needs subject.");
61
+ const resource = subjectType === "agent" ? (agents.get(subject) === "managed" ? "dome_managed_agent" : "dome_agent") : subjectType === "tool_connection" ? "dome_mcp_connection" : subjectType === "model" ? "dome_llm_connection" : null;
62
+ if (!resource) throw new Error(`Quota ${quota.name ?? ""} has unsupported subject_type ${subjectType}.`);
63
+ return block("dome_quota", label(required(quota, "name", "Every quota needs a name.")), [attr("workspace_id", "var.workspace_id"), attr("dimension", q(required(quota, "dimension", "Every quota needs dimension."))), attr("unit", q(required(quota, "unit", "Every quota needs unit."))), attr("subject_type", q(subjectType)), attr("subject", `${resource}.${label(subject)}.name`), attr("scope", q(quota.scope ?? "total")), attr("window", q(required(quota, "window", "Every quota needs window."))), attr("limit_amount", String(required(quota, "limit_amount", "Every quota needs limit_amount."))), attr("name", q(quota.name))]);
64
+ });
65
+ }
66
+
67
+ function managedModelReference(manifest, name) {
68
+ if (manifest.pools.some((pool) => pool.name === name)) return `dome_llm_pool.${label(name)}.name`;
69
+ if (manifest.models.some((model) => model.name === name)) return `dome_llm_connection.${label(name)}.name`;
70
+ throw new Error(`Managed agent default_model ${name} must name a pool or model.`);
71
+ }
72
+
73
+ function triggerBlocks(triggers) {
74
+ return triggers.map((trigger) => {
75
+ const kind = required(trigger, "kind", `Trigger ${trigger.name} needs kind.`);
76
+ if (!["schedule", "webhook"].includes(kind)) throw new Error(`Trigger ${trigger.name} has unsupported kind ${kind}.`);
77
+ if (kind === "schedule" && !trigger.cron) throw new Error(`Schedule trigger ${trigger.name} needs cron.`);
78
+ const lines = [attr("workspace_id", "var.workspace_id"), attr("name", q(required(trigger, "name", "Every trigger needs a name."))), ...(trigger.description ? [attr("description", q(trigger.description))] : []), attr("kind", q(kind))];
79
+ for (const key of ["enabled", "cron", "timezone", "input", "max_concurrent_runs", "catch_up", "catch_up_deadline_seconds", "max_retries"]) if (trigger[key] !== undefined) lines.push(attr(key, typeof trigger[key] === "string" ? q(trigger[key]) : String(trigger[key])));
80
+ const targets = trigger.targets ?? [];
81
+ if (!Array.isArray(targets) || !targets.length) throw new Error(`Trigger ${trigger.name} needs at least one target.`);
82
+ lines.push(`targets = [${targets.map((target) => {
83
+ if (target?.kind !== "managed_agent" || typeof target.name !== "string") throw new Error(`Trigger ${trigger.name} targets must name a managed_agent.`);
84
+ return `{ kind = "managed_agent", name = dome_managed_agent.${label(target.name)}.name${target.disabled === undefined ? "" : `, disabled = ${Boolean(target.disabled)}`} }`;
85
+ }).join(", ")}]`);
86
+ return block("dome_trigger", label(trigger.name), lines);
179
87
  });
180
88
  }
181
89
 
182
- export function renderDomeTerraform(manifest, root) {
183
- const catalog = required(manifest, "catalog", "template.yaml must declare catalog to generate dome.tf.");
184
- const gateway = required(catalog, "gateway", "catalog.gateway is required to generate dome.tf.");
185
- const agent = required(catalog, "agent", "catalog.agent is required to generate dome.tf.");
186
- const gatewayLabel = label(required(gateway, "name", "catalog.gateway needs a name."));
187
- const agentLabel = label(required(agent, "name", "catalog.agent needs a name."));
188
- const variables = new Set();
189
- const blocks = [
190
- block("dome_gateway", gatewayLabel, [
191
- attr("workspace_id", "var.workspace_id"), attr("name", q(gateway.name)),
192
- ...(gateway.description ? [attr("description", q(gateway.description))] : []), attr("is_default", String(Boolean(gateway.default))),
193
- ]),
194
- ...(catalog.connections ?? []).flatMap((connection) => connectionBlocks(connection, gatewayLabel, variables)),
195
- ];
196
- if (catalog.model || catalog.pool) {
197
- if (!catalog.model || !catalog.pool) throw new Error("catalog.model and catalog.pool must be declared together.");
198
- blocks.push(...modelBlocks(catalog.model, catalog.pool, gatewayLabel, variables));
90
+ export function renderDomeTerraform(manifest) {
91
+ if (!manifest.gateways.length) throw new Error("template.yaml must declare at least one gateway.");
92
+ const blocks = [];
93
+ for (const gateway of manifest.gateways) {
94
+ const gatewayLabel = label(gateway.name);
95
+ blocks.push(block("dome_gateway", gatewayLabel, [attr("workspace_id", "var.workspace_id"), attr("name", q(required(gateway, "name", "Every gateway needs a name."))), ...(gateway.description ? [attr("description", q(gateway.description))] : []), attr("is_default", String(Boolean(gateway.is_default)))]));
96
+ for (const group of gateway.tools ?? []) for (const tool of group.tools ?? []) blocks.push(block("dome_gateway_tool", `${gatewayLabel}_${label(group.tool_connection)}_${label(tool)}`, [attr("workspace_id", "var.workspace_id"), attr("gateway", `dome_gateway.${gatewayLabel}.name`), attr("mcp_connection", `dome_mcp_connection.${label(required(group, "tool_connection", "Gateway tools need tool_connection."))}.name`), attr("tool", q(tool))]));
97
+ for (const pool of gateway.pools ?? []) blocks.push(block("dome_gateway_llm_pool", `${gatewayLabel}_${label(pool)}`, [attr("workspace_id", "var.workspace_id"), attr("gateway", `dome_gateway.${gatewayLabel}.name`), attr("llm_pool", `dome_llm_pool.${label(pool)}.name`)]));
98
+ }
99
+ for (const connection of manifest.tool_connections) {
100
+ const auth = connection.auth_method ?? "none";
101
+ const lines = [attr("workspace_id", "var.workspace_id"), attr("name", q(connection.name)), attr("protocol", q(required(connection, "protocol", `Tool connection ${connection.name} needs protocol.`))), attr("url", q(required(connection, "url", `Tool connection ${connection.name} needs url.`))), attr("auth_method", q(auth)), attr("credential_type", q(connection.credential_type ?? (auth === "none" ? "none" : "shared")))];
102
+ if (connection.managed_header_name) lines.push(attr("managed_header_name", q(connection.managed_header_name)));
103
+ if (connection.secret_values) lines.push(attr("secret_values", `{ ${Object.entries(connection.secret_values).map(([key, value]) => `${q(key)} = ${expression(value)}`).join(", ")} }`));
104
+ if (connection.oauth) lines.push(attr("oauth", `jsonencode(${JSON.stringify(connection.oauth)})`));
105
+ blocks.push(block("dome_mcp_connection", label(connection.name), lines));
106
+ }
107
+ for (const model of manifest.models) {
108
+ const lines = [attr("workspace_id", "var.workspace_id"), attr("name", q(model.name)), attr("provider_id", q(required(model, "provider_id", `Model ${model.name} needs provider_id.`))), attr("provider_config", `jsonencode(${JSON.stringify(required(model, "provider_config", `Model ${model.name} needs provider_config.`))})`)];
109
+ for (const key of ["auth_method", "credential_type", "managed_header_name"]) if (model[key]) lines.push(attr(key, q(model[key])));
110
+ if (model.secret_values) lines.push(attr("secret_values", `{ ${Object.entries(model.secret_values).map(([key, value]) => `${q(key)} = ${expression(value)}`).join(", ")} }`));
111
+ blocks.push(block("dome_llm_connection", label(model.name), lines));
112
+ }
113
+ for (const pool of manifest.pools) {
114
+ const poolLabel = label(pool.name);
115
+ blocks.push(block("dome_llm_pool", poolLabel, [attr("workspace_id", "var.workspace_id"), attr("name", q(pool.name)), attr("is_default", String(Boolean(pool.is_default)))]));
116
+ for (const member of pool.members ?? []) blocks.push(block("dome_llm_pool_member", `${poolLabel}_${label(member)}`, [attr("workspace_id", "var.workspace_id"), attr("pool", `dome_llm_pool.${poolLabel}.name`), attr("llm_connection", `dome_llm_connection.${label(member)}.name`)]));
199
117
  }
200
- const actAs = agent.act_as;
201
- const agentLines = [attr("workspace_id", "var.workspace_id"), attr("name", q(agent.name)), attr("allowed_gateways", `[dome_gateway.${gatewayLabel}.name]`)];
202
- if (actAs?.method && actAs.method !== "none") {
203
- agentLines.push(attr("act_as", `{ method = ${q(actAs.method)}, required = ${String(Boolean(actAs.required))} }`));
204
- if (actAs.secret) { const variableName = label(actAs.secret); variables.add(variableName); agentLines.push(attr("act_as_hmac_secret", `var.${variableName}`)); }
205
- if (actAs.allowed_subjects) agentLines.push(attr("actas_allowed_subjects", `[${actAs.allowed_subjects.map(q).join(", ")}]`));
118
+ for (const agent of manifest.agents) {
119
+ const agentLabel = label(agent.name); const managed = agent.type === "managed"; const resourceType = managed ? "dome_managed_agent" : "dome_agent";
120
+ const lines = [attr("workspace_id", "var.workspace_id"), attr("name", q(agent.name))];
121
+ if (!managed) lines.push(attr("allowed_gateways", `[${(agent.allowed_gateways ?? []).map((gateway) => `dome_gateway.${label(gateway)}.name`).join(", ")}]`));
122
+ if (!managed && agent.act_as) {
123
+ const { method, required: actAsRequired, hmac_secret: hmacSecret, allowed_subjects: allowedSubjects } = agent.act_as;
124
+ if (!method) throw new Error(`Agent ${agent.name} act_as needs method.`);
125
+ lines.push(attr("act_as", `{ method = ${q(method)}, required = ${Boolean(actAsRequired)} }`));
126
+ if (hmacSecret) lines.push(attr("act_as_hmac_secret", expression(hmacSecret)));
127
+ if (allowedSubjects) lines.push(attr("actas_allowed_subjects", `[${allowedSubjects.map(q).join(", ")}]`));
128
+ }
129
+ if (managed) {
130
+ if (!agent.system_prompt?.content || !agent.default_model) throw new Error(`Managed agent ${agent.name} needs system_prompt and default_model.`);
131
+ lines.push(attr("system_prompt", "<<-PROMPT"), ...agent.system_prompt.content.split("\n").map((line) => line.trimEnd()), "PROMPT", attr("default_model", managedModelReference(manifest, agent.default_model)), attr("gateway", `dome_gateway.${label(agent.gateway)}.name`));
132
+ }
133
+ blocks.push(block(resourceType, agentLabel, lines), ...rulesBlock(agent, resourceType, agentLabel));
134
+ if (!managed) for (const key of agent.keys ?? []) blocks.push(block("dome_agent_key", `${agentLabel}_${label(required(key, "name", `Agent ${agent.name} keys need a name.`))}`, [attr("workspace_id", "var.workspace_id"), attr("agent", `dome_agent.${agentLabel}.name`), attr("name", q(key.name))]));
206
135
  }
207
- blocks.push(block("dome_agent", agentLabel, agentLines), ...rulesBlock(root, catalog.rules, agentLabel), ...guardBlocks(root, catalog.guards, {}), ...quotaBlocks(catalog.quotas, {}));
208
- blocks.push(block("dome_agent_key", "runtime", [attr("workspace_id", "var.workspace_id"), attr("agent", `dome_agent.${agentLabel}.name`), attr("name", q("runtime"))]));
209
- const header = `terraform {\n required_providers {\n dome = { source = ${q("dome-systems/dome")}, version = ${q(">= 0.1.0")} }\n }\n}\n\nvariable "workspace_id" { type = string }`;
210
- const secretDeclarations = [...variables].sort().map(variable).join("\n\n");
211
- const output = `output ${q(`${agentLabel}_token`)} {\n value = dome_agent_key.runtime.token\n sensitive = true\n}`;
212
- return [header, secretDeclarations, blocks.join("\n\n"), output].filter(Boolean).join("\n\n") + "\n";
136
+ blocks.push(...filterBlocks(manifest.filters), ...quotaBlocks(manifest.quotas, new Map(manifest.agents.map((agent) => [agent.name, agent.type]))), ...triggerBlocks(manifest.triggers));
137
+ const header = `# Generated by @domesystems/templates from template.yaml. Do not edit.\nterraform {\n required_providers {\n dome = { source = ${q("dome-systems/dome")}, version = ${q(">= 0.1.0")} }\n }\n}\n\nvariable "workspace_id" { type = string }`;
138
+ const outputs = manifest.agents.filter((agent) => agent.type === "external").flatMap((agent) => (agent.keys ?? []).map((key) => {
139
+ const agentLabel = label(agent.name); const keyLabel = label(key.name);
140
+ return `output ${q(`${agentLabel}_${keyLabel}_token`)} {\n value = dome_agent_key.${agentLabel}_${keyLabel}.token\n sensitive = true\n}`;
141
+ }));
142
+ return [header, variableBlocks(manifest.variables).join("\n\n"), blocks.join("\n\n"), outputs.join("\n\n")].filter(Boolean).join("\n\n") + "\n";
213
143
  }
214
144
 
215
145
  export function generateDomeTerraform(directory, { output = "dome.tf", force = false, manifest }) {
216
146
  const target = path.resolve(directory, output);
217
147
  if (fs.existsSync(target) && !force) throw new Error(`${target} already exists. Use --force to replace it.`);
218
- fs.writeFileSync(target, renderDomeTerraform(manifest, directory));
148
+ fs.writeFileSync(target, renderDomeTerraform(manifest));
219
149
  return target;
220
150
  }
package/src/manifest.mjs CHANGED
@@ -2,23 +2,112 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseDocument } from "yaml";
4
4
 
5
- export function readManifest(templateDirectory) {
6
- const manifestPath = path.join(templateDirectory, "template.yaml");
7
- if (!fs.statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) {
8
- throw new Error(`No template.yaml found in ${templateDirectory}.`);
9
- }
5
+ function parseYAML(file, label) {
6
+ const document = parseDocument(fs.readFileSync(file, "utf8"));
7
+ if (document.errors.length) throw new Error(`Invalid ${label}: ${document.errors.map((error) => error.message).join("; ")}`);
8
+ return document.toJS() ?? {};
9
+ }
10
+
11
+ function sourcePath(root, reference, label) {
12
+ if (typeof reference !== "string" || !reference.trim()) throw new Error(`${label} path must be a non-empty string.`);
13
+ const target = path.resolve(root, reference);
14
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error(`${label} path must stay inside the template directory.`);
15
+ if (!fs.statSync(target, { throwIfNoEntry: false })?.isFile()) throw new Error(`${label} path does not exist: ${reference}.`);
16
+ return target;
17
+ }
18
+
19
+ function expandAgent(root, entry, index) {
20
+ if (!entry || typeof entry !== "object") throw new Error(`agents[${index}] must be an object or path.`);
21
+ if (entry.path === undefined) return entry;
22
+ if (Object.keys(entry).length !== 1) throw new Error(`agents[${index}] cannot combine path with other fields.`);
23
+ const agent = parseYAML(sourcePath(root, entry.path, `agents[${index}]`), `agent fragment ${entry.path}`);
24
+ if (!agent || typeof agent !== "object" || Array.isArray(agent)) throw new Error(`Agent fragment ${entry.path} must be an object.`);
25
+ return agent;
26
+ }
10
27
 
11
- const document = parseDocument(fs.readFileSync(manifestPath, "utf8"));
12
- if (document.errors.length) {
13
- throw new Error(`Invalid template.yaml: ${document.errors.map((error) => error.message).join("; ")}`);
28
+ function expandFilters(root, filters) {
29
+ if (filters === undefined) return [];
30
+ if (!Array.isArray(filters)) throw new Error("filters must be a list.");
31
+ return filters.flatMap((entry, index) => {
32
+ if (!entry || typeof entry !== "object") throw new Error(`filters[${index}] must be an object or path.`);
33
+ if (entry.path === undefined) return [entry];
34
+ if (Object.keys(entry).length !== 1) throw new Error(`filters[${index}] cannot combine path with other fields.`);
35
+ const parsed = parseYAML(sourcePath(root, entry.path, `filters[${index}]`), `filter fragment ${entry.path}`);
36
+ const items = Array.isArray(parsed) ? parsed : parsed.filters;
37
+ if (!Array.isArray(items)) throw new Error(`filters[${index}] fragment must contain a filters list.`);
38
+ return items;
39
+ });
40
+ }
41
+
42
+ function normalizeFilterConfigs(root, filters) {
43
+ return filters.map((filter, index) => {
44
+ if (!filter?.config?.path) return filter;
45
+ if (Object.keys(filter.config).length !== 1) throw new Error(`filters[${index}].config cannot combine path with other fields.`);
46
+ const file = sourcePath(root, filter.config.path, `filters[${index}].config`);
47
+ try {
48
+ return { ...filter, config: JSON.parse(fs.readFileSync(file, "utf8")) };
49
+ } catch (error) {
50
+ throw new Error(`filters[${index}].config path must contain JSON: ${error.message}`);
51
+ }
52
+ });
53
+ }
54
+
55
+ function normalizeRuleFiles(root, agent, index) {
56
+ const files = agent.rules?.files;
57
+ if (files === undefined) return agent;
58
+ if (!files || typeof files !== "object" || Array.isArray(files)) throw new Error(`agents[${index}].rules.files must be a filename map.`);
59
+ const normalized = {};
60
+ for (const [name, source] of Object.entries(files)) {
61
+ if (!source || typeof source !== "object" || Array.isArray(source)) throw new Error(`agents[${index}].rules.files.${name} must declare path or inline.`);
62
+ const hasPath = typeof source.path === "string";
63
+ const hasInline = typeof source.inline === "string";
64
+ if (hasPath === hasInline) throw new Error(`agents[${index}].rules.files.${name} must declare exactly one of path or inline.`);
65
+ normalized[name] = hasPath ? { content: fs.readFileSync(sourcePath(root, source.path, `agents[${index}].rules.files.${name}`), "utf8"), source: source.path } : { content: source.inline, source: "inline" };
14
66
  }
67
+ return { ...agent, rules: { ...agent.rules, files: normalized } };
68
+ }
15
69
 
16
- const manifest = document.toJS() ?? {};
17
- const runtime = manifest.app?.runtime;
18
- if (!runtime?.pattern || !runtime?.language) {
19
- throw new Error("template.yaml must declare app.runtime.pattern and app.runtime.language.");
70
+ function normalizeManagedAgent(root, agent, index) {
71
+ if (agent.type !== "managed") return agent;
72
+ const prompt = agent.system_prompt;
73
+ if (!prompt || typeof prompt !== "object" || Array.isArray(prompt)) throw new Error(`agents[${index}].system_prompt must declare path or inline.`);
74
+ const hasPath = typeof prompt.path === "string";
75
+ const hasInline = typeof prompt.inline === "string";
76
+ if (hasPath === hasInline) throw new Error(`agents[${index}].system_prompt must declare exactly one of path or inline.`);
77
+ if (typeof agent.gateway !== "string" || !agent.gateway.trim()) throw new Error(`Managed agent ${agent.name} needs gateway.`);
78
+ if (agent.allowed_gateways !== undefined || agent.trigger !== undefined) throw new Error(`Managed agent ${agent.name} uses gateway and top-level triggers; allowed_gateways and trigger are not supported.`);
79
+ return { ...agent, system_prompt: { content: hasPath ? fs.readFileSync(sourcePath(root, prompt.path, `agents[${index}].system_prompt`), "utf8") : prompt.inline, source: hasPath ? prompt.path : "inline" } };
80
+ }
81
+
82
+ function named(items, label) {
83
+ const names = new Set();
84
+ for (const item of items ?? []) {
85
+ if (typeof item?.name !== "string" || !item.name.trim()) throw new Error(`${label} entries need a name.`);
86
+ if (names.has(item.name)) throw new Error(`${label} names must be unique; ${item.name} appears more than once.`);
87
+ names.add(item.name);
20
88
  }
21
- return manifest;
89
+ }
90
+
91
+ export function readManifest(templateDirectory) {
92
+ const root = path.resolve(templateDirectory);
93
+ const manifestPath = path.join(root, "template.yaml");
94
+ if (!fs.statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) throw new Error(`No template.yaml found in ${templateDirectory}.`);
95
+ const manifest = parseYAML(manifestPath, "template.yaml");
96
+ if (manifest.apiVersion !== "templates.domesystems.ai/v1" || manifest.kind !== "Template") throw new Error("template.yaml must declare apiVersion templates.domesystems.ai/v1 and kind Template.");
97
+ if (!Array.isArray(manifest.agents) || manifest.agents.length === 0) throw new Error("template.yaml must declare at least one agent.");
98
+ const agents = manifest.agents.map((entry, index) => {
99
+ const agent = normalizeManagedAgent(root, normalizeRuleFiles(root, expandAgent(root, entry, index), index), index);
100
+ if (typeof agent.name !== "string" || !agent.name.trim()) throw new Error(`agents[${index}].name is required.`);
101
+ const type = agent.type;
102
+ if (!type) throw new Error(`agents[${index}].type is required.`);
103
+ if (!["external", "managed"].includes(type)) throw new Error(`agents[${index}].type must be external or managed.`);
104
+ if (type === "managed" && agent.keys?.length) throw new Error(`agents[${index}] is managed and cannot declare keys.`);
105
+ return { ...agent, type };
106
+ });
107
+ const filters = normalizeFilterConfigs(root, expandFilters(root, manifest.filters));
108
+ const triggers = manifest.triggers ?? [];
109
+ named(agents, "agents"); named(manifest.gateways ?? [], "gateways"); named(manifest.tool_connections ?? [], "tool_connections"); named(manifest.models ?? [], "models"); named(manifest.pools ?? [], "pools"); named(filters, "filters"); named(triggers, "triggers");
110
+ return { ...manifest, variables: manifest.variables ?? {}, gateways: manifest.gateways ?? [], tool_connections: manifest.tool_connections ?? [], models: manifest.models ?? [], pools: manifest.pools ?? [], filters, quotas: manifest.quotas ?? [], triggers, agents };
22
111
  }
23
112
 
24
113
  const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
@@ -26,30 +115,11 @@ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.
26
115
 
27
116
  export function readPublishManifest(templateDirectory) {
28
117
  const manifest = readManifest(templateDirectory);
29
- if (manifest.apiVersion !== "templates.domesystems.ai/v1" || manifest.kind !== "Template") {
30
- throw new Error("template.yaml must declare apiVersion templates.domesystems.ai/v1 and kind Template.");
31
- }
32
118
  const metadata = manifest.metadata ?? {};
33
- if (!PACKAGE_NAME.test(metadata.name ?? "")) {
34
- throw new Error("template.yaml metadata.name must be a lowercase scoped name such as im2nguyen/tinyfish.");
35
- }
36
- if (!VERSION.test(metadata.version ?? "")) {
37
- throw new Error("template.yaml metadata.version must be a valid SemVer version.");
38
- }
39
- if (typeof metadata.title !== "string" || !metadata.title.trim()) {
40
- throw new Error("template.yaml metadata.title is required to publish a template.");
41
- }
119
+ if (!PACKAGE_NAME.test(metadata.name ?? "")) throw new Error("template.yaml metadata.name must be a lowercase scoped name such as im2nguyen/tinyfish.");
120
+ if (!VERSION.test(metadata.version ?? "")) throw new Error("template.yaml metadata.version must be a valid SemVer version.");
121
+ if (typeof metadata.title !== "string" || !metadata.title.trim()) throw new Error("template.yaml metadata.title is required to publish a template.");
42
122
  const source = metadata.source;
43
- if (!source || typeof source.repository !== "string" || typeof source.path !== "string" || !source.path.trim()) {
44
- throw new Error("template.yaml metadata.source.repository and metadata.source.path are required to publish a template.");
45
- }
46
- let repository;
47
- try { repository = new URL(source.repository); } catch { throw new Error("metadata.source.repository must be a GitHub HTTPS URL."); }
48
- if (repository.protocol !== "https:" || repository.hostname !== "github.com" || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(repository.pathname)) {
49
- throw new Error("metadata.source.repository must be a public https://github.com/<owner>/<repository> URL.");
50
- }
51
- if (source.path.split(/[\\/]+/).some((part) => !part || part === "." || part === "..")) {
52
- throw new Error("metadata.source.path must be a relative repository path without traversal.");
53
- }
123
+ if (!source || typeof source.repository !== "string" || typeof source.path !== "string" || !source.path.trim()) throw new Error("template.yaml metadata.source.repository and metadata.source.path are required to publish a template.");
54
124
  return manifest;
55
125
  }
package/src/runtime.mjs CHANGED
@@ -6,18 +6,30 @@ const runtimeImages = {
6
6
  };
7
7
 
8
8
  export function imageForRuntime(runtime) {
9
- if (runtime.image) return runtime.image;
10
9
  const key = `${runtime.language}:${runtime.pattern}`;
11
10
  const image = runtimeImages[key];
12
- if (!image) {
13
- const supported = Object.keys(runtimeImages).join(", ");
14
- throw new Error(`Unsupported runtime ${key}. Set app.runtime.image explicitly, or use one of: ${supported}.`);
15
- }
16
- return `${image}:${runtime.image_tag ?? "latest"}`;
11
+ if (!image) throw new Error(`Unsupported runtime ${key}. Use one of: ${Object.keys(runtimeImages).join(", ")}.`);
12
+ return `${image}:latest`;
17
13
  }
18
14
 
19
- export function renderCompose(runtime, hostPort = 3000, { debug = false } = {}) {
15
+ export function renderCompose(runtime, hostPort = 3000, { debug = false, configPath = "app" } = {}) {
20
16
  const image = imageForRuntime(runtime);
21
17
  const diagnostics = debug ? " environment:\n DOME_DEBUG: \"true\"\n" : "";
22
- return `# Generated by @domesystems/templates. Do not edit; update template.yaml instead.\nservices:\n app:\n image: ${image}\n env_file: .env\n${diagnostics} volumes:\n - ./app:/config:ro\n - ./template.yaml:/template.yaml:ro\n ports:\n - \"${hostPort}:3000\"\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:3000/health', timeout=2)\"]\n interval: 30s\n timeout: 3s\n start_period: 10s\n retries: 3\n`;
18
+ return `# Generated by @domesystems/templates. Do not edit; update template.yaml instead.
19
+ services:
20
+ app:
21
+ image: ${image}
22
+ env_file: .env
23
+ ${diagnostics} volumes:
24
+ - ./${configPath}:/config:ro
25
+ - ./template.yaml:/template.yaml:ro
26
+ ports:
27
+ - "${hostPort}:3000"
28
+ healthcheck:
29
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3000/health', timeout=2)"]
30
+ interval: 30s
31
+ timeout: 3s
32
+ start_period: 10s
33
+ retries: 3
34
+ `;
23
35
  }