@domesystems/templates 0.3.7 → 0.4.0

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/README.md CHANGED
@@ -34,7 +34,7 @@ templates up [--dir <directory>] [--port <host-port>] [--server <URL>|--registry
34
34
  templates publish [--dry-run] [--server <URL>|--registry <URL>]
35
35
  ```
36
36
 
37
- `pull` only accepts scoped package names, verifies the Registry SHA-256 before extraction, accepts only the archive's `template/` root, writes `.dome-template-lock.yaml`, and never overwrites a non-empty directory. `generate` reads `catalog` in `template.yaml` and writes a `dome.tf` with the matching Gateway, connections, model pool, agent, Rules, Guards, quotas, and runtime key. Generated model pools are non-default so templates can be installed into workspaces that already have a default pool. It never replaces an existing file unless you pass `--force`; review the generated Terraform before importing it. `up` is local-only and never downloads or updates a release. When it provisions an unconfigured runtime, it reveals the import's one-time agent token, resolves the workspace default Gateway, and writes only `DOME_TOKEN` and `DOME_GATEWAY_URL` to the ignored `.env` before starting Docker. Pass `--debug` to enable safe runtime diagnostics for that Compose run; it logs outbound Dome endpoint, status/outcome, and timing but not secrets or request content. `publish` reads the scoped name and SemVer version from `template.yaml`, requires a clean Git checkout, and cannot replace a published version.
37
+ `pull` only accepts scoped package names, verifies the Registry SHA-256 before extraction, accepts only the archive's `template/` root, writes `.dome-template-lock.yaml`, and never overwrites a non-empty directory. `generate` reads `catalog` in `template.yaml` and writes a `dome.tf` with the matching Gateway, connections, model pool, agent, Rules, Guards, quotas, and runtime key. Generated model pools are non-default so templates can be installed into workspaces that already have a default pool. It never replaces an existing file unless you pass `--force`; review the generated Terraform before importing it. `up` is local-only and never downloads or updates a release. When it provisions an unconfigured runtime, it reveals the import's one-time agent token, resolves the workspace default Gateway, and writes only `DOME_TOKEN` and `DOME_GATEWAY_URL` to the ignored `.env` before refreshing the configured runtime image and starting Docker. Pass `--debug` to enable safe runtime diagnostics for that Compose run; it logs outbound Dome endpoint, status/outcome, and timing but not secrets or request content. `publish` reads the scoped name and SemVer version from `template.yaml`, requires a clean Git checkout, and cannot replace a published version.
38
38
 
39
39
  ## Publishing from a GitHub organization
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domesystems/templates",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
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,7 +60,29 @@ 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.`); }
79
+ export function composeCommands({ detach = false } = {}) {
80
+ const prefix = ["compose", "-f", ".dome-compose.yaml"];
81
+ return {
82
+ pull: [...prefix, "pull"],
83
+ up: [...prefix, "up", ...(detach ? ["--detach"] : [])],
84
+ };
85
+ }
63
86
  function json(text, command) { try { return JSON.parse(text.slice(text.indexOf("{"))); } catch { throw new Error(`${command} did not return valid JSON.`); } }
64
87
  function runJSON(command, args, cwd, { reveal = false } = {}) {
65
88
  return new Promise((resolve, reject) => {
@@ -87,8 +110,27 @@ function configured(target) { const env = path.join(target, ".env"); return fs.e
87
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); }
88
111
  export function importHasPendingOutputs(job) { return (job?.outputs_state ?? job?.outputsState) === "pending"; }
89
112
  export function importCreatedResources(job) { const summary = job?.plan_summary ?? job?.planSummary; return Number(summary?.create_count ?? summary?.createCount ?? 0) > 0; }
90
- 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)); }
91
- 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) { output(line("info", `Starting app at http://localhost:${options.port}`, options)); run("docker", ["compose", "-f", ".dome-compose.yaml", "up", ...(options.detach ? ["--detach"] : [])], 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
+ }
92
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 } };
93
135
  const remote = remoteTarget(options); let registry; let attempt;
94
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,121 @@ 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))]);
179
64
  });
180
65
  }
181
66
 
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));
67
+ export function renderDomeTerraform(manifest) {
68
+ if (!manifest.gateways.length) throw new Error("template.yaml must declare at least one gateway.");
69
+ const blocks = [];
70
+ for (const gateway of manifest.gateways) {
71
+ const gatewayLabel = label(gateway.name);
72
+ 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)))]));
73
+ 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))]));
74
+ 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`)]));
75
+ }
76
+ for (const connection of manifest.tool_connections) {
77
+ const auth = connection.auth_method ?? "none";
78
+ 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")))];
79
+ if (connection.managed_header_name) lines.push(attr("managed_header_name", q(connection.managed_header_name)));
80
+ if (connection.secret_values) lines.push(attr("secret_values", `{ ${Object.entries(connection.secret_values).map(([key, value]) => `${q(key)} = ${expression(value)}`).join(", ")} }`));
81
+ if (connection.oauth) lines.push(attr("oauth", `jsonencode(${JSON.stringify(connection.oauth)})`));
82
+ blocks.push(block("dome_mcp_connection", label(connection.name), lines));
83
+ }
84
+ for (const model of manifest.models) {
85
+ 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.`))})`)];
86
+ for (const key of ["auth_method", "credential_type", "managed_header_name"]) if (model[key]) lines.push(attr(key, q(model[key])));
87
+ if (model.secret_values) lines.push(attr("secret_values", `{ ${Object.entries(model.secret_values).map(([key, value]) => `${q(key)} = ${expression(value)}`).join(", ")} }`));
88
+ blocks.push(block("dome_llm_connection", label(model.name), lines));
89
+ }
90
+ for (const pool of manifest.pools) {
91
+ const poolLabel = label(pool.name);
92
+ 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)))]));
93
+ 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
94
  }
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(", ")}]`));
95
+ for (const agent of manifest.agents) {
96
+ const agentLabel = label(agent.name); const managed = agent.type === "managed"; const resourceType = managed ? "dome_managed_agent" : "dome_agent";
97
+ const lines = [attr("workspace_id", "var.workspace_id"), attr("name", q(agent.name)), attr("allowed_gateways", `[${(agent.allowed_gateways ?? []).map((gateway) => `dome_gateway.${label(gateway)}.name`).join(", ")}]`)];
98
+ if (!managed && agent.act_as) {
99
+ const { method, required: actAsRequired, hmac_secret: hmacSecret, allowed_subjects: allowedSubjects } = agent.act_as;
100
+ if (!method) throw new Error(`Agent ${agent.name} act_as needs method.`);
101
+ lines.push(attr("act_as", `{ method = ${q(method)}, required = ${Boolean(actAsRequired)} }`));
102
+ if (hmacSecret) lines.push(attr("act_as_hmac_secret", expression(hmacSecret)));
103
+ if (allowedSubjects) lines.push(attr("actas_allowed_subjects", `[${allowedSubjects.map(q).join(", ")}]`));
104
+ }
105
+ if (managed) {
106
+ if (!agent.system_prompt?.path || !agent.default_model) throw new Error(`Managed agent ${agent.name} needs system_prompt.path and default_model.`);
107
+ lines.push(attr("system_prompt", `file("${'${path.module}'}/${agent.system_prompt.path}")`), attr("default_model", `dome_llm_pool.${label(agent.default_model)}.name`));
108
+ const trigger = agent.trigger ?? {}; const config = { ...(trigger.manual === undefined ? {} : { manual: { enabled: Boolean(trigger.manual) } }), ...(trigger.webhook === undefined ? {} : { webhook: { enabled: Boolean(trigger.webhook) } }), ...(trigger.schedule ? { schedule: trigger.schedule } : {}) };
109
+ if (Object.keys(config).length) lines.push(attr("trigger_config", `jsonencode(${JSON.stringify(config)})`));
110
+ }
111
+ blocks.push(block(resourceType, agentLabel, lines), ...rulesBlock(agent, resourceType, agentLabel));
112
+ 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
113
  }
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";
114
+ blocks.push(...filterBlocks(manifest.filters), ...quotaBlocks(manifest.quotas, new Map(manifest.agents.map((agent) => [agent.name, agent.type]))));
115
+ 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 }`;
116
+ const outputs = manifest.agents.filter((agent) => agent.type === "external").flatMap((agent) => (agent.keys ?? []).map((key) => {
117
+ const agentLabel = label(agent.name); const keyLabel = label(key.name);
118
+ return `output ${q(`${agentLabel}_${keyLabel}_token`)} {\n value = dome_agent_key.${agentLabel}_${keyLabel}.token\n sensitive = true\n}`;
119
+ }));
120
+ return [header, variableBlocks(manifest.variables).join("\n\n"), blocks.join("\n\n"), outputs.join("\n\n")].filter(Boolean).join("\n\n") + "\n";
213
121
  }
214
122
 
215
123
  export function generateDomeTerraform(directory, { output = "dome.tf", force = false, manifest }) {
216
124
  const target = path.resolve(directory, output);
217
125
  if (fs.existsSync(target) && !force) throw new Error(`${target} already exists. Use --force to replace it.`);
218
- fs.writeFileSync(target, renderDomeTerraform(manifest, directory));
126
+ fs.writeFileSync(target, renderDomeTerraform(manifest));
219
127
  return target;
220
128
  }
package/src/manifest.mjs CHANGED
@@ -2,23 +2,99 @@ 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
+ }
27
+
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
+ }
10
41
 
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("; ")}`);
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 named(items, label) {
71
+ const names = new Set();
72
+ for (const item of items ?? []) {
73
+ if (typeof item?.name !== "string" || !item.name.trim()) throw new Error(`${label} entries need a name.`);
74
+ if (names.has(item.name)) throw new Error(`${label} names must be unique; ${item.name} appears more than once.`);
75
+ names.add(item.name);
20
76
  }
21
- return manifest;
77
+ }
78
+
79
+ export function readManifest(templateDirectory) {
80
+ const root = path.resolve(templateDirectory);
81
+ const manifestPath = path.join(root, "template.yaml");
82
+ if (!fs.statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) throw new Error(`No template.yaml found in ${templateDirectory}.`);
83
+ const manifest = parseYAML(manifestPath, "template.yaml");
84
+ 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.");
85
+ if (!Array.isArray(manifest.agents) || manifest.agents.length === 0) throw new Error("template.yaml must declare at least one agent.");
86
+ const agents = manifest.agents.map((entry, index) => {
87
+ const agent = normalizeRuleFiles(root, expandAgent(root, entry, index), index);
88
+ if (typeof agent.name !== "string" || !agent.name.trim()) throw new Error(`agents[${index}].name is required.`);
89
+ const type = agent.type;
90
+ if (!type) throw new Error(`agents[${index}].type is required.`);
91
+ if (!["external", "managed"].includes(type)) throw new Error(`agents[${index}].type must be external or managed.`);
92
+ if (type === "managed" && agent.keys?.length) throw new Error(`agents[${index}] is managed and cannot declare keys.`);
93
+ return { ...agent, type };
94
+ });
95
+ const filters = normalizeFilterConfigs(root, expandFilters(root, manifest.filters));
96
+ named(agents, "agents"); named(manifest.gateways ?? [], "gateways"); named(manifest.tool_connections ?? [], "tool_connections"); named(manifest.models ?? [], "models"); named(manifest.pools ?? [], "pools"); named(filters, "filters");
97
+ return { ...manifest, variables: manifest.variables ?? {}, gateways: manifest.gateways ?? [], tool_connections: manifest.tool_connections ?? [], models: manifest.models ?? [], pools: manifest.pools ?? [], filters, quotas: manifest.quotas ?? [], agents };
22
98
  }
23
99
 
24
100
  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 +102,11 @@ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.
26
102
 
27
103
  export function readPublishManifest(templateDirectory) {
28
104
  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
105
  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
- }
106
+ if (!PACKAGE_NAME.test(metadata.name ?? "")) throw new Error("template.yaml metadata.name must be a lowercase scoped name such as im2nguyen/tinyfish.");
107
+ if (!VERSION.test(metadata.version ?? "")) throw new Error("template.yaml metadata.version must be a valid SemVer version.");
108
+ if (typeof metadata.title !== "string" || !metadata.title.trim()) throw new Error("template.yaml metadata.title is required to publish a template.");
42
109
  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
- }
110
+ 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
111
  return manifest;
55
112
  }
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
  }