@domesystems/templates 0.1.1 → 0.2.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.
Files changed (33) hide show
  1. package/README.md +41 -150
  2. package/package.json +5 -5
  3. package/src/cli.mjs +100 -26
  4. package/src/runtime.mjs +2 -2
  5. package/src/template-files.mjs +72 -0
  6. package/ai-agent-for-documentation/.env.example +0 -12
  7. package/ai-agent-for-documentation/app/agent.yaml +0 -31
  8. package/ai-agent-for-documentation/app/help.md +0 -19
  9. package/ai-agent-for-documentation/app/instructions.md +0 -21
  10. package/ai-agent-for-documentation/docker-compose.yml +0 -18
  11. package/ai-agent-for-documentation/dome.tf +0 -146
  12. package/ai-agent-for-documentation/rules/answerer.cedar +0 -35
  13. package/ai-agent-for-documentation/template.yaml +0 -131
  14. package/ai-agent-for-documentation/verification.json +0 -7
  15. package/ai-agent-for-notion/.env.example +0 -19
  16. package/ai-agent-for-notion/app/agent.yaml +0 -32
  17. package/ai-agent-for-notion/app/help.md +0 -14
  18. package/ai-agent-for-notion/app/instructions.md +0 -16
  19. package/ai-agent-for-notion/docker-compose.yml +0 -18
  20. package/ai-agent-for-notion/dome.tf +0 -163
  21. package/ai-agent-for-notion/guards/redact-ssn-from-pages.json +0 -22
  22. package/ai-agent-for-notion/rules/answerer.cedar +0 -29
  23. package/ai-agent-for-notion/template.yaml +0 -143
  24. package/ai-agent-for-notion/verification.json +0 -7
  25. package/ai-agent-for-release-notes/.env.example +0 -15
  26. package/ai-agent-for-release-notes/app/agent.yaml +0 -36
  27. package/ai-agent-for-release-notes/app/help.md +0 -20
  28. package/ai-agent-for-release-notes/app/instructions.md +0 -19
  29. package/ai-agent-for-release-notes/docker-compose.yml +0 -18
  30. package/ai-agent-for-release-notes/dome.tf +0 -165
  31. package/ai-agent-for-release-notes/guards/block-leaked-keys.json +0 -38
  32. package/ai-agent-for-release-notes/rules/release-notes.cedar +0 -49
  33. package/ai-agent-for-release-notes/template.yaml +0 -156
package/README.md CHANGED
@@ -1,168 +1,59 @@
1
- # Dome templates
1
+ # Dome Templates CLI
2
2
 
3
- Reusable, importable governed-agent templates. Each template combines an HCL
4
- configuration bundle for Dome with an optional companion application.
3
+ Download, configure, and run governed-agent templates from the Dome Template
4
+ Library. The package contains no template source: each template is fetched as a
5
+ versioned ZIP archive from the Library when you run `init` or `up`.
5
6
 
6
- ## Use the template CLI
7
+ ## Install and run
7
8
 
8
- The public package is also an executable. It copies the template, creates a
9
- local `.env` from `.env.example`, and generates the Compose file from the
10
- runtime declaration in `template.yaml` — individual templates do not need a
11
- Dockerfile or a committed Compose file.
9
+ Use `npx`; Node.js 20 or newer is required.
12
10
 
13
11
  ```bash
14
- # Create a working copy in ./ai-agent-for-documentation.
12
+ # Download a template into ./ai-agent-for-documentation.
15
13
  npx @domesystems/templates init ai-agent-for-documentation
16
14
 
17
- # Initialize when needed, provision Dome, then start the selected runtime.
15
+ # Download if needed, provision Dome, and run it locally.
18
16
  npx @domesystems/templates up ai-agent-for-documentation
19
17
  ```
20
18
 
21
- `up` runs `dome import dome.tf`, then `docker compose` with a generated,
22
- Git-ignored `.dome-compose.yaml`. Use `--no-start` to provision without
23
- starting a container, `--skip-provision` to start an already-provisioned
24
- template, `--detach` for a background container, and `--dir <directory>` to
25
- choose a local destination.
19
+ By default, archives come from
20
+ `https://templates.domesystems.ai/templates/downloads/<template>.zip`.
26
21
 
27
- Runtime selection is explicit rather than guessed:
28
-
29
- ```yaml
30
- app:
31
- runtime:
32
- pattern: chat # chat or run
33
- language: python # python or typescript
34
- image_tag: "0.1.0" # optional; defaults to latest
35
- ```
36
-
37
- The CLI maps `language` and `pattern` to a shared, published runtime image.
38
- For an unusual runtime, set `app.runtime.image` to a complete image reference
39
- (including its tag) to override that mapping.
40
- Add a template directory to the package's `files` list when publishing a new
41
- template so the CLI can discover it.
42
-
43
- ## Use a template
22
+ For local Library development, point the CLI at a local download server:
44
23
 
45
24
  ```bash
46
- git clone https://github.com/dome-systems/templates.git
47
- cd templates/<template>
48
-
49
- brew install dome-systems/tap/dome
50
- dome auth login
51
- dome sandbox provision
52
-
53
- # Preview resources, variables, and required permissions. Changes nothing.
54
- dome import dome.tf --plan-only
55
-
56
- # Apply after reviewing the preview. Dome prompts for required variables.
57
- dome import dome.tf
25
+ DOME_TEMPLATES_BASE_URL=http://localhost:3001/templates/downloads \
26
+ npx @domesystems/templates init ai-agent-for-documentation
58
27
  ```
59
28
 
60
- Imports run as asynchronous jobs in Dome. Existing resources are converged by
61
- name, missing resources are created, and any destructive replacement is
62
- refused. Secrets are input variables: they are never committed to a template
63
- or returned in an export.
64
-
65
- Every built companion app needs the one-time agent token created by the import:
66
-
67
- ```bash
68
- dome import outputs <job-id>
69
- cp .env.example .env
70
- docker compose up
71
- ```
72
-
73
- Tool and model credentials remain in Dome; the app only holds its agent token.
74
-
75
- ### Runtime image
76
-
77
- Templates do not build a runtime themselves. In development, clone the sibling
78
- `dome-systems/template-runtime` repository and build the archetype image before
79
- starting a template:
80
-
81
- ```bash
82
- cd template-runtime
83
- docker build -f Dockerfile.chat -t ghcr.io/dome-systems/runtime-py-chat:dev .
84
- docker build -f Dockerfile.run -t ghcr.io/dome-systems/runtime-py-run:dev .
85
- ```
86
-
87
- TypeScript equivalents are available as `ghcr.io/dome-systems/runtime-ts-chat`
88
- and `ghcr.io/dome-systems/runtime-ts-run`; substitute either image in a template's Compose file
89
- when using the Node runtime.
90
-
91
- The CLI-generated Compose configuration uses the appropriate image and mounts
92
- only the template's local `app/` configuration. Release templates should set a
93
- versioned `app.runtime.image_tag` rather than relying on `latest`.
29
+ ## Commands
94
30
 
95
- ## Runtime contract
96
-
97
- Every built companion app follows the same small operational contract:
98
-
99
- | Surface | Guarantee |
100
- |---|---|
101
- | `.env.example` | The complete committed list of values a local operator may need. It contains names and setup guidance, never real values. |
102
- | `docker-compose.yml` | Starts the app on port `3000`, loads `.env`, mounts `app/` read-only, and declares the health check. |
103
- | `GET /health` | Liveness only: returns `200 {"status":"ok"}` without calling Dome or an upstream system. Offline mode is valid. `/healthz` is an equivalent compatibility alias. |
104
- | `GET /readyz` | Readiness of the initialized runtime API. Returns `200`; its `mode` is `live` or `offline`. |
105
- | Docker `HEALTHCHECK` | Probes `/health`; inherited by every shared runtime image and repeated in Compose for a visible local contract. |
106
-
107
- The Library renders each template's committed `.env.example` verbatim, so setup
108
- requirements have one source of truth. Add a new variable there when the
109
- companion app needs it; use `app.options.env` in `template.yaml` as well when
110
- that value is interpolated into an agent prompt, starter, or help text.
111
-
112
- ## Archetype containers
113
-
114
- The separate `dome-systems/template-runtime` repository builds one image per app
115
- shape. They contain no template-specific code — a template mounts an
116
- `app/agent.yaml` giving its **prompt**, its **skills** and its **invoker**, which
117
- is the shape the Dome agent runtime will take, so swapping a container for the
118
- real runner is a config move rather than a rewrite.
119
-
120
- Four UIs cover all 249 templates. A report is a run whose output is a table; an approval
121
- console is a queue with two extra buttons.
122
-
123
- | Archetype | Covers | Shapes | State |
124
- |---|---|---|---|
125
- | `ghcr.io/dome-systems/runtime-py-chat` | 66 | interactive-assistant | Built |
126
- | `ghcr.io/dome-systems/runtime-py-run` | 120 | scheduled-job, report-dashboard | Built |
127
- | `dome/template-queue` | 48 | triage-queue, approval-console | Not built |
128
- | `dome/template-console` | 15 | external-service, developer-harness | Not built |
129
-
130
- The governance drawer is shared chrome across all four, not a per-archetype feature.
131
-
132
- Tools go through the Gateway over MCP; models go through the Broker's
133
- Anthropic-compatible ingress. Both authenticate with the same agent token, which is why
134
- the container holds one secret and no provider key. Without `DOME_TOKEN` it starts in
135
- offline mode: the chrome works and the allow-list is still enforced, but tools and model
136
- are simulated.
137
-
138
- ## What is source and what is generated
139
-
140
- `dome.tf` is the configuration source of truth for Dome resources. It is a
141
- portable HCL bundle that `dome import` can inspect, preview, and apply.
142
- `template.yaml` is catalog metadata and the source of truth for choosing the
143
- shared local runtime; `rules/*.cedar` and `app/instructions.md` are
144
- human-authored source.
145
-
146
- ## Relationship to the Template Library
147
-
148
- This repository is the Library's only template-content source. A merge to `main`
149
- dispatches its commit SHA to `dome-systems/web-library`; that repository checks out
150
- and builds the exact revision, then opens or updates one source-refresh PR. The
151
- Library does not copy or edit template content, and a refresh PR must be merged
152
- before the new source can be published.
153
-
154
- The dispatch uses the `LIBRARY_SOURCE_SYNC_TOKEN` repository secret. It needs a
155
- fine-grained token that may dispatch workflows in `dome-systems/web-library`.
156
-
157
- ## Validation
158
-
159
- Run the metadata and source-reference checks locally:
160
-
161
- ```bash
162
- npm install
163
- npm run validate
164
- terraform fmt -check -recursive
31
+ ```text
32
+ npx @domesystems/templates init <template> [--dir <directory>] [--force]
33
+ npx @domesystems/templates up <template> [--dir <directory>] [--port <host-port>] [--skip-provision] [--provision] [--no-start] [--detach] [--verbose] [--force]
165
34
  ```
166
35
 
167
- CI runs the same checks. A plan-only import against a designated Dome sandbox can
168
- be added when CI has an appropriate non-production workspace and credentials.
36
+ | Command | Option | What it does |
37
+ |---|---|---|
38
+ | `init` | `--dir <directory>` | Downloads into this directory. Defaults to `./<template>`. |
39
+ | `init` | `--force` | Allows a non-empty destination; downloaded source files can replace existing files. |
40
+ | `up` | `--dir <directory>` | Uses this existing template directory, or downloads it when it does not exist. |
41
+ | `up` | `--port <host-port>` | Publishes the runtime at `http://localhost:<host-port>`; default `3000`. |
42
+ | `up` | `--skip-provision` | Does not run `dome import`. |
43
+ | `up` | `--provision` | Re-imports Dome resources even when local credentials already exist. |
44
+ | `up` | `--no-start` | Does not start Docker after setup. |
45
+ | `up` | `--detach` | Starts Docker Compose in the background. |
46
+ | `up` | `--verbose` | Shows generated Compose details and the full Dome import response. |
47
+ | `up` | `--force` | Allows a non-empty destination only when `up` needs to download it. |
48
+
49
+ `up` reuses a configured `.env` by default, so routine restarts do not import
50
+ resources again. Use `--provision` after changing the template’s Dome
51
+ resources. Status markers are colored in compatible terminals; set `NO_COLOR=1`
52
+ to turn colors off.
53
+
54
+ ## Security and source
55
+
56
+ The CLI accepts only archives whose contents live below the requested template
57
+ directory. It rejects traversal paths, unsupported archive entries, oversized
58
+ downloads, and archives without `template.yaml` before copying files into your
59
+ project. The Template Library controls the ZIP contents and current revision.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@domesystems/templates",
3
- "version": "0.1.1",
4
- "description": "Initialize and run Dome governed-agent templates.",
3
+ "version": "0.2.0",
4
+ "description": "Download, initialize, and run Dome governed-agent templates.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dome-templates": "bin/dome-templates.mjs"
@@ -9,9 +9,7 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "ai-agent-for-documentation",
13
- "ai-agent-for-notion",
14
- "ai-agent-for-release-notes"
12
+ "README.md"
15
13
  ],
16
14
  "engines": {
17
15
  "node": ">=20"
@@ -24,6 +22,8 @@
24
22
  "test": "node --test"
25
23
  },
26
24
  "dependencies": {
25
+ "unzipper": "0.12.3",
26
+ "unzipper": "0.12.3",
27
27
  "yaml": "2.9.0"
28
28
  }
29
29
  }
package/src/cli.mjs CHANGED
@@ -1,16 +1,29 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { spawn, spawnSync } from "node:child_process";
5
4
  import readline from "node:readline/promises";
6
5
  import { readManifest } from "./manifest.mjs";
7
6
  import { imageForRuntime, renderCompose } from "./runtime.mjs";
8
- import { copyTemplate, initializeEnvironment } from "./template-files.mjs";
7
+ import { downloadTemplate, initializeEnvironment } from "./template-files.mjs";
9
8
 
10
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
10
+
11
+ function color(code, text) {
12
+ return supportsColor ? `\u001B[${code}m${text}\u001B[0m` : text;
13
+ }
14
+
15
+ function status(kind, message) {
16
+ const styles = {
17
+ info: ["●", "36"],
18
+ success: ["✓", "32"],
19
+ warning: ["→", "33"],
20
+ };
21
+ const [marker, code] = styles[kind];
22
+ return `${color(code, marker)} ${message}`;
23
+ }
11
24
 
12
25
  function usage() {
13
- return `Usage:\n npx @domesystems/templates init <template> [--dir <directory>] [--force]\n npx @domesystems/templates up <template> [--dir <directory>] [--skip-provision] [--no-start] [--detach] [--force]\n\nCommands:\n init Copy a template locally and create .env from .env.example.\n up Initialize (when needed), generate Compose from template.yaml, provision Dome, and start it.\n\nThe generated .dome-compose.yaml is ignored by Git. Runtime images are selected\nfrom app.runtime.language and app.runtime.pattern in template.yaml.\n`;
26
+ return `Usage:\n npx @domesystems/templates init <template> [--dir <directory>] [--force]\n npx @domesystems/templates up <template> [--dir <directory>] [--port <host-port>] [--skip-provision] [--provision] [--no-start] [--detach] [--verbose] [--force]\n\nCommands:\n init Download a template locally and create .env from .env.example.\n up Download when needed, generate Compose from template.yaml, provision Dome, and start it.\n\nTemplates are downloaded from templates.domesystems.ai by default. A configured\nlocal .env skips provisioning automatically. Use --provision to re-sync\nexisting resources. Use --port to choose the local host port (default: 3000).\nThe generated .dome-compose.yaml is ignored by Git. Use --verbose to show the\nfull Dome import response for troubleshooting.\n`;
14
27
  }
15
28
 
16
29
  function fail(message) {
@@ -20,13 +33,21 @@ function fail(message) {
20
33
  function parseArguments(argv) {
21
34
  if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { help: true };
22
35
  const [command, slug, ...rest] = argv;
23
- const options = { force: false, provision: true, start: true, detach: false, directory: undefined };
36
+ const options = { force: false, provision: true, forceProvision: false, start: true, detach: false, verbose: false, directory: undefined, port: 3000 };
24
37
  for (let index = 0; index < rest.length; index += 1) {
25
38
  const argument = rest[index];
26
39
  if (argument === "--force") options.force = true;
27
40
  else if (argument === "--skip-provision") options.provision = false;
41
+ else if (argument === "--provision") options.forceProvision = true;
28
42
  else if (argument === "--no-start") options.start = false;
29
43
  else if (argument === "--detach") options.detach = true;
44
+ else if (argument === "--verbose") options.verbose = true;
45
+ else if (argument === "--port") {
46
+ const port = Number(rest[index + 1]);
47
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port requires an integer from 1 to 65535.");
48
+ options.port = port;
49
+ index += 1;
50
+ }
30
51
  else if (argument === "--dir") {
31
52
  options.directory = rest[index + 1];
32
53
  if (!options.directory) fail("--dir requires a directory.");
@@ -40,15 +61,10 @@ function parseArguments(argv) {
40
61
  return { command, slug, options };
41
62
  }
42
63
 
43
- function templateSource(slug) {
64
+ function validateTemplateSlug(slug) {
44
65
  if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
45
66
  fail(`Invalid template slug: ${slug}`);
46
67
  }
47
- const source = path.join(packageRoot, slug);
48
- if (!fs.statSync(path.join(source, "template.yaml"), { throwIfNoEntry: false })?.isFile()) {
49
- fail(`Unknown template: ${slug}`);
50
- }
51
- return source;
52
68
  }
53
69
 
54
70
  function targetDirectory(slug, directory) {
@@ -59,15 +75,19 @@ function isEmpty(directory) {
59
75
  return !fs.existsSync(directory) || fs.readdirSync(directory).length === 0;
60
76
  }
61
77
 
62
- function init(slug, options, output) {
63
- const source = templateSource(slug);
78
+ async function init(slug, options, output, fetchImplementation) {
79
+ validateTemplateSlug(slug);
64
80
  const target = targetDirectory(slug, options.directory);
65
81
  if (!isEmpty(target) && !options.force) {
66
82
  throw new Error(`${target} already exists and is not empty. Choose --dir or pass --force.`);
67
83
  }
68
- copyTemplate(source, target);
84
+ output(status("info", `Downloading ${slug}…`));
85
+ const archiveURL = await downloadTemplate(slug, target, {
86
+ baseURL: process.env.DOME_TEMPLATES_BASE_URL,
87
+ fetchImplementation,
88
+ });
69
89
  const environmentCreated = initializeEnvironment(target);
70
- output(`Initialized ${slug} in ${target}.`);
90
+ output(status("success", `Downloaded ${slug} from ${archiveURL}.`));
71
91
  if (environmentCreated) {
72
92
  output("Created .env from .env.example.");
73
93
  output("When you run `up`, Dome will prompt for import credentials; they are never written to .env.");
@@ -76,10 +96,18 @@ function init(slug, options, output) {
76
96
  return target;
77
97
  }
78
98
 
79
- function resolveUpDirectory(slug, options, output) {
99
+ async function resolveUpDirectory(slug, options, output, fetchImplementation) {
80
100
  const target = targetDirectory(slug, options.directory);
81
- if (fs.statSync(path.join(target, "template.yaml"), { throwIfNoEntry: false })?.isFile()) return target;
82
- return init(slug, options, output);
101
+ if (fs.statSync(path.join(target, "template.yaml"), { throwIfNoEntry: false })?.isFile()) {
102
+ const environmentCreated = initializeEnvironment(target);
103
+ if (environmentCreated) {
104
+ output("Created .env from .env.example.");
105
+ output("When you run `up`, Dome will prompt for import credentials; they are never written to .env.");
106
+ output("After import, `up` writes DOME_TOKEN and DOME_GATEWAY_URL into .env. Add any other template-specific runtime values before starting.");
107
+ }
108
+ return target;
109
+ }
110
+ return init(slug, options, output, fetchImplementation);
83
111
  }
84
112
 
85
113
  function run(command, args, cwd) {
@@ -104,10 +132,24 @@ function runJSON(command, args, cwd, { displayOutput = true } = {}) {
104
132
  return new Promise((resolve, reject) => {
105
133
  const child = spawn(command, args, { cwd, stdio: ["inherit", "pipe", "inherit"] });
106
134
  let output = "";
135
+ let displayed = 0;
107
136
  child.stdout.on("data", (chunk) => {
108
137
  const text = chunk.toString();
109
138
  output += text;
110
- if (displayOutput) process.stdout.write(text);
139
+ if (displayOutput === true) {
140
+ process.stdout.write(text);
141
+ displayed = output.length;
142
+ } else if (displayOutput === "before-final-json") {
143
+ const match = /(?:^|\n)\{/.exec(output);
144
+ if (match) {
145
+ const jsonStart = match.index + (match[0][0] === "\n" ? 1 : 0);
146
+ if (jsonStart > displayed) process.stdout.write(output.slice(displayed, jsonStart));
147
+ displayed = jsonStart;
148
+ } else {
149
+ process.stdout.write(text);
150
+ displayed = output.length;
151
+ }
152
+ }
111
153
  });
112
154
  child.on("error", (error) => reject(new Error(`Could not run ${command}: ${error.message}`)));
113
155
  child.on("close", (status) => {
@@ -124,6 +166,15 @@ function runJSON(command, args, cwd, { displayOutput = true } = {}) {
124
166
  });
125
167
  }
126
168
 
169
+ function importSummary(response) {
170
+ const results = response.data?.job?.results ?? [];
171
+ const counts = new Map();
172
+ for (const result of results) counts.set(result.action, (counts.get(result.action) ?? 0) + 1);
173
+ if (counts.size === 0) return "Dome import completed.";
174
+ const descriptions = [...counts].map(([action, count]) => `${count} ${action}${count === 1 ? "" : "s"}`);
175
+ return `Dome import complete: ${descriptions.join(", ")}.`;
176
+ }
177
+
127
178
  function replaceEnvironmentValue(source, name, value) {
128
179
  const expression = new RegExp(`^${name}=.*$`, "m");
129
180
  if (!expression.test(source)) throw new Error(`The runtime .env is missing ${name}.`);
@@ -137,6 +188,16 @@ function undefinedEnvironmentValues(source) {
137
188
  .filter(Boolean);
138
189
  }
139
190
 
191
+ function runtimeEnvironmentIsConfigured(target) {
192
+ const environmentPath = path.join(target, ".env");
193
+ if (!fs.statSync(environmentPath, { throwIfNoEntry: false })?.isFile()) return false;
194
+ const environment = fs.readFileSync(environmentPath, "utf8");
195
+ const missing = new Set(undefinedEnvironmentValues(environment));
196
+ return ["DOME_TOKEN", "DOME_GATEWAY_URL"].every(
197
+ (name) => new RegExp(`^${name}=`, "m").test(environment) && !missing.has(name),
198
+ );
199
+ }
200
+
140
201
  async function fillRuntimeEnvironment(target, output) {
141
202
  const environmentPath = path.join(target, ".env");
142
203
  let environment = fs.readFileSync(environmentPath, "utf8");
@@ -198,7 +259,7 @@ async function writeRuntimeEnvironment(target, manifest, importResponse, output)
198
259
  output(`Configured ${environmentPath} with the one-time agent token and Gateway URL.`);
199
260
  }
200
261
 
201
- export async function main(argv, { output = console.log, error = console.error } = {}) {
262
+ export async function main(argv, { output = console.log, error = console.error, fetchImplementation = globalThis.fetch } = {}) {
202
263
  try {
203
264
  const parsed = parseArguments(argv);
204
265
  if (parsed.help) {
@@ -207,22 +268,35 @@ export async function main(argv, { output = console.log, error = console.error }
207
268
  }
208
269
  const { command, slug, options } = parsed;
209
270
  if (command === "init") {
210
- init(slug, options, output);
271
+ await init(slug, options, output, fetchImplementation);
211
272
  return;
212
273
  }
213
274
 
214
- const target = resolveUpDirectory(slug, options, output);
275
+ const target = await resolveUpDirectory(slug, options, output, fetchImplementation);
215
276
  const manifest = readManifest(target);
216
277
  const composePath = path.join(target, ".dome-compose.yaml");
217
- fs.writeFileSync(composePath, renderCompose(manifest.app.runtime));
218
- output(`Generated ${path.basename(composePath)} using ${imageForRuntime(manifest.app.runtime)}.`);
278
+ fs.writeFileSync(composePath, renderCompose(manifest.app.runtime, options.port));
279
+ if (options.verbose) {
280
+ output(status("info", `Generated ${path.basename(composePath)} with ${imageForRuntime(manifest.app.runtime)} for http://localhost:${options.port}.`));
281
+ }
219
282
 
220
- if (options.provision) {
221
- const imported = await runJSON("dome", ["import", "dome.tf", "--format", "json"], target);
283
+ const alreadyProvisioned = runtimeEnvironmentIsConfigured(target);
284
+ if (options.provision && alreadyProvisioned && !options.forceProvision) {
285
+ const nextStep = options.start
286
+ ? "Dome is already set up — starting the app. To update Dome resources later, use --provision."
287
+ : "Dome is already set up. To update Dome resources later, use --provision.";
288
+ output(status("success", nextStep));
289
+ } else if (options.provision) {
290
+ output(status("info", "Setting up Dome resources…"));
291
+ const imported = await runJSON("dome", ["import", "dome.tf", "--format", "json"], target, {
292
+ displayOutput: options.verbose ? true : "before-final-json",
293
+ });
294
+ if (!options.verbose) output(status("success", importSummary(imported)));
222
295
  await writeRuntimeEnvironment(target, manifest, imported, output);
223
296
  }
224
297
  if (options.start) {
225
298
  await fillRuntimeEnvironment(target, output);
299
+ output(status("info", `Starting app at http://localhost:${options.port}…`));
226
300
  const args = ["compose", "-f", ".dome-compose.yaml", "up"];
227
301
  if (options.detach) args.push("--detach");
228
302
  run("docker", args, target);
package/src/runtime.mjs CHANGED
@@ -16,7 +16,7 @@ export function imageForRuntime(runtime) {
16
16
  return `${image}:${runtime.image_tag ?? "latest"}`;
17
17
  }
18
18
 
19
- export function renderCompose(runtime) {
19
+ export function renderCompose(runtime, hostPort = 3000) {
20
20
  const image = imageForRuntime(runtime);
21
- return `# Generated by @domesystems/templates. Do not edit; update template.yaml instead.\nservices:\n app:\n image: ${image}\n env_file: .env\n volumes:\n - ./app:/config:ro\n ports:\n - \"3000: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`;
21
+ return `# Generated by @domesystems/templates. Do not edit; update template.yaml instead.\nservices:\n app:\n image: ${image}\n env_file: .env\n volumes:\n - ./app:/config: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`;
22
22
  }
@@ -1,7 +1,30 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import os from "node:os";
4
+ import unzipper from "unzipper";
3
5
 
4
6
  const excludedNames = new Set([".env", ".git", ".terraform", "node_modules", "docker-compose.yml", ".dome-compose.yaml"]);
7
+ const maximumArchiveBytes = 50 * 1024 * 1024;
8
+ const maximumExtractedBytes = 100 * 1024 * 1024;
9
+
10
+ function archivePath(relative) {
11
+ const normalized = relative.replaceAll("\\", "/");
12
+ const withoutTrailingSlash = normalized.replace(/\/+$/, "");
13
+ if (!withoutTrailingSlash || normalized.startsWith("/") || normalized.includes("\0") || withoutTrailingSlash.split("/").some((part) => part === "." || part === "..")) {
14
+ throw new Error(`Template archive contains an unsafe path: ${relative}`);
15
+ }
16
+ return withoutTrailingSlash;
17
+ }
18
+
19
+ function relativeArchivePath(entryPath, slug) {
20
+ const normalized = archivePath(entryPath);
21
+ if (normalized === slug) return undefined;
22
+ const prefix = `${slug}/`;
23
+ if (!normalized.startsWith(prefix)) throw new Error(`Template archive must contain files under ${prefix}`);
24
+ const relative = normalized.slice(prefix.length);
25
+ if (!relative) throw new Error("Template archive contains an invalid empty path.");
26
+ return relative;
27
+ }
5
28
 
6
29
  export function copyTemplate(source, destination) {
7
30
  fs.mkdirSync(destination, { recursive: true });
@@ -26,3 +49,52 @@ export function initializeEnvironment(templateDirectory) {
26
49
  }
27
50
  return false;
28
51
  }
52
+
53
+ export async function downloadTemplate(slug, destination, { baseURL, fetchImplementation = globalThis.fetch } = {}) {
54
+ const archiveURL = new URL(`${slug}.zip`, `${(baseURL ?? "https://templates.domesystems.ai/templates/downloads").replace(/\/$/, "")}/`);
55
+ const response = await fetchImplementation(archiveURL, { headers: { accept: "application/zip" } });
56
+ if (!response.ok) throw new Error(`Could not download ${slug}: ${response.status} ${response.statusText}.`);
57
+
58
+ const archive = Buffer.from(await response.arrayBuffer());
59
+ if (archive.length === 0 || archive.length > maximumArchiveBytes) throw new Error(`Template archive must be between 1 byte and ${maximumArchiveBytes / 1024 / 1024} MiB.`);
60
+
61
+ let directory;
62
+ try {
63
+ directory = await unzipper.Open.buffer(archive);
64
+ } catch {
65
+ throw new Error(`Downloaded ${slug} is not a valid ZIP archive.`);
66
+ }
67
+
68
+ let extractedBytes = 0;
69
+ const entries = directory.files
70
+ .map((entry) => ({ entry, relative: relativeArchivePath(entry.path, slug) }))
71
+ .filter(({ relative }) => relative !== undefined);
72
+ for (const { entry, relative } of entries) {
73
+ if (entry.type !== "File" && entry.type !== "Directory") throw new Error(`Template archive contains an unsupported entry: ${relative}`);
74
+ if (entry.type === "File") {
75
+ extractedBytes += entry.uncompressedSize;
76
+ if (extractedBytes > maximumExtractedBytes) throw new Error(`Template archive expands beyond ${maximumExtractedBytes / 1024 / 1024} MiB.`);
77
+ }
78
+ }
79
+
80
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), "dome-template-"));
81
+ try {
82
+ const source = path.join(staging, slug);
83
+ for (const { entry, relative } of entries) {
84
+ const target = path.join(source, relative);
85
+ if (entry.type === "Directory") {
86
+ fs.mkdirSync(target, { recursive: true });
87
+ } else {
88
+ fs.mkdirSync(path.dirname(target), { recursive: true });
89
+ fs.writeFileSync(target, await entry.buffer());
90
+ }
91
+ }
92
+ if (!fs.statSync(path.join(source, "template.yaml"), { throwIfNoEntry: false })?.isFile()) {
93
+ throw new Error(`Downloaded ${slug} does not contain template.yaml.`);
94
+ }
95
+ copyTemplate(source, destination);
96
+ } finally {
97
+ fs.rmSync(staging, { recursive: true, force: true });
98
+ }
99
+ return archiveURL.toString();
100
+ }
@@ -1,12 +0,0 @@
1
- # The container holds exactly one secret.
2
- #
3
- # Tool credentials went into Dome when dome import ran and live behind the gateway.
4
- # The provider key lives with the Broker. Neither is here, and neither should be.
5
-
6
- # Revealed once after import: dome import outputs <job-id>
7
- DOME_TOKEN=
8
-
9
- # The gateway ROOT — not a bare host, and not the /mcp path.
10
- # Copy the "Gateway URL" from the command above. The container derives
11
- # /mcp for tools and /v1/messages for models from it.
12
- DOME_GATEWAY_URL=
@@ -1,31 +0,0 @@
1
- name: docs-answerer
2
- title: Ask questions about product documentation
3
- summary: "A chat agent that answers questions from the Dome Platform documentation.\
4
- \ Nothing to set up upstream \u2014 the documentation MCP server takes no credential,\
5
- \ so this reaches a real system on the first run. The connection carries three tools.\
6
- \ Two answer questions. The third returns the whole documentation index, and the\
7
- \ agent will reach for it \u2014 Dome refuses, and names the rule that refused it."
8
- shape: interactive-assistant
9
- prompt_file: instructions.md
10
- help_file: help.md
11
- model: claude-haiku-4-5-20251001
12
- pool: docs-pool
13
- max_tokens: 4096
14
- skills:
15
- - name: answer-from-documentation
16
- description: Search the documentation, read the most relevant pages, answer from
17
- them.
18
- tools:
19
- - domedocs/search
20
- - domedocs/get_page
21
- - domedocs/list_pages
22
- invoker:
23
- kind: chat
24
- options:
25
- identity:
26
- mode: none
27
- model:
28
- provider: anthropic
29
- model: claude-haiku-4-5-20251001
30
- forbidden:
31
- - domedocs/list_pages
@@ -1,19 +0,0 @@
1
- Ask a question about the Dome Platform in plain language. The agent searches the
2
- public documentation at `docs.domesystems.ai`, reads the pages it finds, and
3
- answers from them.
4
-
5
- **Nothing to set up upstream.** The documentation MCP server takes no
6
- credential, so this template reaches a real system on its first run. The only
7
- key the workspace needs is a model provider key — every template needs one —
8
- and it goes to Dome, not into this container.
9
-
10
- **Ask something broad.** "What is Dome" or "what does the gateway do". The agent
11
- will reach for the documentation index to orient itself, that call will come
12
- back refused, and you will watch it fall back to search and answer anyway. The
13
- refusal is the rule, not the prompt.
14
-
15
- **Show detail** in the header opens the design: the topology, which tools are
16
- permitted, and the instructions the model was given.
17
-
18
- Nothing here is the record. Every call, allowed or refused, is in the audit log
19
- in Dome.
@@ -1,21 +0,0 @@
1
- You answer questions about the Dome Platform from its documentation.
2
-
3
- Work like this:
4
-
5
- 1. If the question is broad — "what is Dome", "how does authorization work" —
6
- start from the documentation index to see what exists.
7
- 2. Search the documentation for the terms in the question. Search results carry
8
- the path of every page they match.
9
- 3. Read pages rather than searching repeatedly. Two or three searches is
10
- plenty; after that, open the most promising pages in full **using the paths
11
- the search results gave you**. Do not invent a path and try it.
12
- 4. Answer from what you read, and name the pages you used.
13
-
14
- If the documentation does not contain the answer, say so plainly. Do not fill
15
- the gap from general knowledge — a confident answer that is not in the
16
- documentation is worse than no answer, because the reader cannot tell the
17
- difference.
18
-
19
- Use whatever tools you have been given, and do not decline a request on the
20
- assumption that you lack permission. If a call comes back refused, say what came
21
- back and carry on with the tools that do work.