@domesystems/templates 0.3.3 → 0.3.5
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 +8 -2
- package/package.json +1 -1
- package/src/cli.mjs +28 -8
- package/src/generate.mjs +220 -0
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Pull immutable Dome template releases, publish public templates from GitHub, and
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @domesystems/templates login --registry https://templates-registry.domesystems.ai --github
|
|
7
7
|
npx @domesystems/templates pull dome-systems/ai-agent-for-documentation --registry https://templates-registry.domesystems.ai --dir ai-agent-for-documentation
|
|
8
|
+
npx @domesystems/templates generate --dir ai-agent-for-documentation
|
|
8
9
|
npx @domesystems/templates up --dir ai-agent-for-documentation
|
|
9
10
|
```
|
|
10
11
|
|
|
@@ -28,11 +29,16 @@ templates login [--server <URL>|--registry <URL>] [--github]
|
|
|
28
29
|
templates logout [--server <URL>|--registry <URL>]
|
|
29
30
|
templates doctor <pull|up|publish> [--dir <directory>] [--server <URL>|--registry <URL>] [--json]
|
|
30
31
|
templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>|--registry <URL>]
|
|
31
|
-
templates
|
|
32
|
+
templates generate [--dir <directory>] [--output <file>] [--force]
|
|
33
|
+
templates up [--dir <directory>] [--port <host-port>] [--server <URL>|--registry <URL>] [--import-job <job-id>] [--skip-provision|--provision] [--no-start] [--detach] [--verbose]
|
|
32
34
|
templates publish [--dry-run] [--server <URL>|--registry <URL>]
|
|
33
35
|
```
|
|
34
36
|
|
|
35
|
-
`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. `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. `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 starting Docker. `publish` reads the scoped name and SemVer version from `template.yaml`, requires a clean Git checkout, and cannot replace a published version.
|
|
38
|
+
|
|
39
|
+
## Publishing from a GitHub organization
|
|
40
|
+
|
|
41
|
+
Run `templates publish` from the directory containing `template.yaml`. The package scope must be the repository owner's exact GitHub handle: a template in `https://github.com/acme/templates` can publish as `acme/example`, not a personal handle. Before publishing, link GitHub and authorize the Dome Templates Registry App for the source repository. For organization repositories, the linked GitHub user must be a repository administrator. Use `templates doctor publish` to check both Registry sign-in and GitHub publishing access before uploading an artifact.
|
|
36
42
|
|
|
37
43
|
Use `--verbose` with a command to include its diagnostic stack trace when it fails. When publishing requires a GitHub connection, the CLI opens the authorization page automatically; complete it, then run `publish` again.
|
|
38
44
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { readManifest, readPublishManifest } from "./manifest.mjs";
|
|
5
|
+
import { generateDomeTerraform } from "./generate.mjs";
|
|
5
6
|
import { imageForRuntime, renderCompose } from "./runtime.mjs";
|
|
6
7
|
import { extractRelease, initializeEnvironment, writeRuntimeEnvironment } from "./template-files.mjs";
|
|
7
8
|
import { createArchive, publishSource } from "./publish-files.mjs";
|
|
@@ -9,19 +10,19 @@ import { DEFAULT_REGISTRY, clearSelectedTarget, deleteProfile, directRegistry, d
|
|
|
9
10
|
|
|
10
11
|
function ansi(code, value, options) { return options.color && process.stdout.isTTY ? `\u001B[${code}m${value}\u001B[0m` : value; }
|
|
11
12
|
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}`; }
|
|
12
|
-
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 up [--dir <directory>] [--port <host-port>] [--server <URL>|--registry <URL>] [--import-job <job-id>] [--skip-provision|--provision] [--no-start] [--detach] [--verbose]\n templates publish [--dry-run] [--server <Dome application URL>|--registry <Registry URL>]\n"; }
|
|
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] [--verbose]\n templates publish [--dir <directory>] [--dry-run] [--server <Dome application URL>|--registry <Registry URL>]\n"; }
|
|
13
14
|
function parse(argv) {
|
|
14
15
|
if (!argv.length || argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
15
16
|
const [command, ...rest] = argv;
|
|
16
|
-
const options = { server: undefined, directory: undefined, port: 3000, dryRun: false, json: false, github: false, importJob: undefined, provision: true, forceProvision: false, start: true, detach: false, verbose: false, color: !process.env.NO_COLOR };
|
|
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, verbose: false, color: !process.env.NO_COLOR };
|
|
17
18
|
const values = [];
|
|
18
19
|
for (let i = 0; i < rest.length; i += 1) {
|
|
19
20
|
const arg = rest[i];
|
|
20
|
-
if (arg === "--server" || arg === "--registry" || arg === "--dir" || 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 === "--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."); } }
|
|
21
|
-
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 === "--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 === "--verbose") options.verbose = true; else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); else values.push(arg);
|
|
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
|
+
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 === "--verbose") options.verbose = true; else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); else values.push(arg);
|
|
22
23
|
}
|
|
23
24
|
if (options.server && options.registry) throw new Error("Use either --server or --registry, not both.");
|
|
24
|
-
if (!["login", "logout", "doctor", "pull", "up", "publish"].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
25
|
+
if (!["login", "logout", "doctor", "pull", "generate", "up", "publish"].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
25
26
|
return { command, values, options };
|
|
26
27
|
}
|
|
27
28
|
function remoteTarget(options) { return options.registry ? directRegistry(options.registry) : options.server ?? readSelectedTarget() ?? directRegistry(DEFAULT_REGISTRY); }
|
|
@@ -52,6 +53,12 @@ async function pull(name, options, output, fetchImplementation) {
|
|
|
52
53
|
fs.mkdirSync(target, { recursive: true }); await extractRelease(Buffer.from(await response.arrayBuffer()), target, release); const created = initializeEnvironment(target);
|
|
53
54
|
output(line("success", `Pulled ${release.name}@${release.version}`, options)); output(` Saved to ${target}`); if (created) output(line("success", "Created .env from .env.example", options));
|
|
54
55
|
}
|
|
56
|
+
function generate(options, output) {
|
|
57
|
+
const target = path.resolve(process.cwd(), options.directory ?? ".");
|
|
58
|
+
const manifest = readManifest(target);
|
|
59
|
+
const file = generateDomeTerraform(target, { output: options.output, force: options.force, manifest });
|
|
60
|
+
output(line("success", `Generated ${file}`, options));
|
|
61
|
+
}
|
|
55
62
|
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.`); }
|
|
56
63
|
function json(text, command) { try { return JSON.parse(text.slice(text.indexOf("{"))); } catch { throw new Error(`${command} did not return valid JSON.`); } }
|
|
57
64
|
function runJSON(command, args, cwd, { reveal = false } = {}) {
|
|
@@ -63,12 +70,25 @@ function runJSON(command, args, cwd, { reveal = false } = {}) {
|
|
|
63
70
|
child.on("close", (status) => status === 0 ? resolve(json(stdout, command)) : reject(new Error(`${command} ${args.join(" ")} failed.`)));
|
|
64
71
|
});
|
|
65
72
|
}
|
|
73
|
+
export function importExportURL(settingsURL) {
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(settingsURL.trim());
|
|
76
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/import-export`;
|
|
77
|
+
return url.toString();
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function importDashboardURL(cwd) {
|
|
83
|
+
const result = spawnSync("dome", ["open", "settings", "--quiet"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
84
|
+
return result.status === 0 ? importExportURL(result.stdout) : null;
|
|
85
|
+
}
|
|
66
86
|
function configured(target) { const env = path.join(target, ".env"); return fs.existsSync(env) && ["DOME_TOKEN", "DOME_GATEWAY_URL"].every((key) => new RegExp(`^${key}=(?!\\s*$|undefined|\"\")`, "m").test(fs.readFileSync(env, "utf8"))); }
|
|
67
87
|
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); }
|
|
68
88
|
export function importHasPendingOutputs(job) { return (job?.outputs_state ?? job?.outputsState) === "pending"; }
|
|
69
89
|
export function importCreatedResources(job) { const summary = job?.plan_summary ?? job?.planSummary; return Number(summary?.create_count ?? summary?.createCount ?? 0) > 0; }
|
|
70
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)); }
|
|
71
|
-
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)); 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));
|
|
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)); 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); } }
|
|
72
92
|
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 } };
|
|
73
93
|
const remote = remoteTarget(options); let registry; let attempt;
|
|
74
94
|
try { ({ target: registry, body: attempt } = await registryRequest(remote, "/v1/publish-attempts", { method: "POST", body: { ...payload, dry_run: options.dryRun }, authenticated: true, fetchImplementation })); } catch (caught) {
|
|
@@ -78,7 +98,7 @@ async function publish(options, output, fetchImplementation) { const target = pa
|
|
|
78
98
|
if (options.dryRun) { output(line("success", `Publish plan is valid for ${name}@${manifest.metadata.version}`, options)); return; }
|
|
79
99
|
try { output(line("info", "Uploading release artifact", options)); const upload = await fetchImplementation(attempt.upload_url, { method: "PUT", headers: { "content-type": "application/zip" }, body: archive.buffer }); if (!upload.ok) throw new Error(`Artifact upload failed: ${upload.status} ${upload.statusText}.`); const { body: published } = await registryRequest(registry, `/v1/publish-attempts/${encodeURIComponent(attempt.id)}/complete`, { method: "POST", body: { sha256: archive.sha256, size_bytes: archive.buffer.length }, authenticated: true, fetchImplementation }); output(line("success", `Published ${published.name ?? name}@${published.version ?? manifest.metadata.version}`, options)); if (published.url) output(` ${published.url}`); } finally { archive.cleanup(); }
|
|
80
100
|
}
|
|
81
|
-
async function doctor(subject, options, output, fetchImplementation) { const checks = []; const add = (name, passed, remediation) => checks.push({ name, passed, remediation }); const remote = remoteTarget(options); try { if (subject === "pull") { if (typeof remote === "string") await discover(remote, fetchImplementation); add("Registry reachability", true); } else if (subject === "up") { const target = path.resolve(process.cwd(), options.directory ?? "."); readManifest(target); add("template.yaml", true); add("Docker Compose", spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0, "Install Docker Desktop or Docker Engine with the Compose plugin."); add("Dome CLI", spawnSync("dome", ["--version"], { stdio: "ignore" }).status === 0, "Install the Dome CLI."); } else if (subject === "publish") {
|
|
101
|
+
async function doctor(subject, options, output, fetchImplementation) { const checks = []; const add = (name, passed, remediation) => checks.push({ name, passed, remediation }); const remote = remoteTarget(options); try { if (subject === "pull") { if (typeof remote === "string") await discover(remote, fetchImplementation); add("Registry reachability", true); } else if (subject === "up") { const target = path.resolve(process.cwd(), options.directory ?? "."); readManifest(target); add("template.yaml", true); add("Docker Compose", spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0, "Install Docker Desktop or Docker Engine with the Compose plugin."); add("Dome CLI", spawnSync("dome", ["--version"], { stdio: "ignore" }).status === 0, "Install the Dome CLI."); } else if (subject === "publish") { const target = path.resolve(process.cwd(), options.directory ?? "."); const manifest = readPublishManifest(target); const source = publishSource(target); const profile = readProfile(remote.server ?? remote); add("Registry sign-in", Boolean(profile?.token), `Run npx @domesystems/templates login ${remoteLocation(options)}`); if (profile?.token) { const { body } = await registryRequest(remote, "/v1/github/access", { method: "POST", body: { repository: manifest.metadata.source.repository, path: manifest.metadata.source.path, commit: source.commit }, authenticated: true, fetchImplementation }); add("GitHub publishing access", body.repository_authorized === true, body.message ?? "Link GitHub, authorize the Dome Templates Registry App for the source repository, and ensure you are a repository administrator."); } } else throw new Error("doctor expects pull, up, or publish."); } catch (error) { add("Preflight", false, error.message); }
|
|
82
102
|
if (options.json) output(JSON.stringify({ server: remote.registry ?? remote, checks })); else checks.forEach((check) => output(line(check.passed ? "success" : "error", check.passed ? check.name : `${check.name}: ${check.remediation}`, options))); if (checks.some((check) => !check.passed)) throw new Error(`Cannot continue until ${subject} checks pass.`);
|
|
83
103
|
}
|
|
84
|
-
export async function main(argv, { output = console.log, error = console.error, fetchImplementation = globalThis.fetch, sleep, exit = (code) => { process.exitCode = code; }, open = openBrowser } = {}) { let options; try { const parsed = parse(argv); if (parsed.help) return output(usage()); const { command, values } = parsed; options = parsed.options; const remote = remoteTarget(options); if (options.json) options.color = false; if (command === "login") { const destination = options.registry ?? options.server ?? (typeof remote === "string" ? remote : remote.registry); output(line("info", `Opening sign-in for ${destination}`, options)); const session = await login(remote, { github: options.github, fetchImplementation, sleep, onStart: (started) => { open(started.verification_uri); output(options.verbose ? `Opening ${started.verification_uri}.` : `If your browser didn't open, open ${started.verification_uri} and enter ${started.user_code ?? "the displayed code"}.`); } }); writeSelectedTarget(remote); output(line("success", `Signed in as ${session.session.email}`, options)); return; } if (command === "logout") { const server = remote.server ?? remote; const profile = readProfile(server); if (profile?.token) await registryRequest(remote, "/v1/auth/logout", { method: "POST", authenticated: true, fetchImplementation }); deleteProfile(server); clearSelectedTarget(remote); output(line("success", `Signed out of ${server}`, options)); return; } if (command === "pull") return await pull(values[0], options, output, fetchImplementation); if (command === "up") return await up(options, output); if (command === "publish") return await publish(options, output, fetchImplementation); return await doctor(values[0], options, output, fetchImplementation); } catch (caught) { const detail = options?.verbose && caught?.stack ? caught.stack : caught.message; error(line("info", `Error: ${detail}`, { color: false })); exit(1); } }
|
|
104
|
+
export async function main(argv, { output = console.log, error = console.error, fetchImplementation = globalThis.fetch, sleep, exit = (code) => { process.exitCode = code; }, open = openBrowser } = {}) { let options; try { const parsed = parse(argv); if (parsed.help) return output(usage()); const { command, values } = parsed; options = parsed.options; const remote = remoteTarget(options); if (options.json) options.color = false; if (command === "login") { const destination = options.registry ?? options.server ?? (typeof remote === "string" ? remote : remote.registry); output(line("info", `Opening sign-in for ${destination}`, options)); const session = await login(remote, { github: options.github, fetchImplementation, sleep, onStart: (started) => { open(started.verification_uri); output(options.verbose ? `Opening ${started.verification_uri}.` : `If your browser didn't open, open ${started.verification_uri} and enter ${started.user_code ?? "the displayed code"}.`); } }); writeSelectedTarget(remote); output(line("success", `Signed in as ${session.session.email}`, options)); return; } if (command === "logout") { const server = remote.server ?? remote; const profile = readProfile(server); if (profile?.token) await registryRequest(remote, "/v1/auth/logout", { method: "POST", authenticated: true, fetchImplementation }); deleteProfile(server); clearSelectedTarget(remote); output(line("success", `Signed out of ${server}`, options)); return; } if (command === "pull") return await pull(values[0], options, output, fetchImplementation); if (command === "generate") return generate(options, output); if (command === "up") return await up(options, output); if (command === "publish") return await publish(options, output, fetchImplementation); return await doctor(values[0], options, output, fetchImplementation); } catch (caught) { const detail = options?.verbose && caught?.stack ? caught.stack : caught.message; error(line("info", `Error: ${detail}`, { color: false })); exit(1); } }
|
package/src/generate.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const label = (value) => {
|
|
5
|
+
const rendered = String(value ?? "").replace(/[^A-Za-z0-9_]/g, "_").replace(/^[^A-Za-z_]/, "_");
|
|
6
|
+
if (!rendered) throw new Error("Template resources need a name.");
|
|
7
|
+
return rendered;
|
|
8
|
+
};
|
|
9
|
+
const q = (value) => JSON.stringify(String(value));
|
|
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)}`;
|
|
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
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
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
|
+
])];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function guardBlocks(root, guards, refs) {
|
|
125
|
+
const filters = guards?.filters ?? [];
|
|
126
|
+
const blocks = [];
|
|
127
|
+
const bindings = new Map();
|
|
128
|
+
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]);
|
|
144
|
+
}
|
|
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
|
+
]));
|
|
155
|
+
}
|
|
156
|
+
return blocks;
|
|
157
|
+
}
|
|
158
|
+
|
|
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
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
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));
|
|
199
|
+
}
|
|
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(", ")}]`));
|
|
206
|
+
}
|
|
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";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function generateDomeTerraform(directory, { output = "dome.tf", force = false, manifest }) {
|
|
216
|
+
const target = path.resolve(directory, output);
|
|
217
|
+
if (fs.existsSync(target) && !force) throw new Error(`${target} already exists. Use --force to replace it.`);
|
|
218
|
+
fs.writeFileSync(target, renderDomeTerraform(manifest, directory));
|
|
219
|
+
return target;
|
|
220
|
+
}
|