@domesystems/templates 0.1.0 → 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.
- package/README.md +41 -150
- package/bin/dome-templates.mjs +1 -1
- package/package.json +5 -5
- package/src/cli.mjs +219 -26
- package/src/runtime.mjs +2 -2
- package/src/template-files.mjs +72 -0
- package/ai-agent-for-documentation/.env.example +0 -12
- package/ai-agent-for-documentation/app/agent.yaml +0 -31
- package/ai-agent-for-documentation/app/help.md +0 -19
- package/ai-agent-for-documentation/app/instructions.md +0 -21
- package/ai-agent-for-documentation/docker-compose.yml +0 -18
- package/ai-agent-for-documentation/dome.tf +0 -146
- package/ai-agent-for-documentation/rules/answerer.cedar +0 -35
- package/ai-agent-for-documentation/template.yaml +0 -130
- package/ai-agent-for-documentation/verification.json +0 -7
- package/ai-agent-for-notion/.env.example +0 -19
- package/ai-agent-for-notion/app/agent.yaml +0 -32
- package/ai-agent-for-notion/app/help.md +0 -14
- package/ai-agent-for-notion/app/instructions.md +0 -16
- package/ai-agent-for-notion/docker-compose.yml +0 -18
- package/ai-agent-for-notion/dome.tf +0 -163
- package/ai-agent-for-notion/guards/redact-ssn-from-pages.json +0 -22
- package/ai-agent-for-notion/rules/answerer.cedar +0 -29
- package/ai-agent-for-notion/template.yaml +0 -142
- package/ai-agent-for-notion/verification.json +0 -7
- package/ai-agent-for-release-notes/.env.example +0 -15
- package/ai-agent-for-release-notes/app/agent.yaml +0 -36
- package/ai-agent-for-release-notes/app/help.md +0 -20
- package/ai-agent-for-release-notes/app/instructions.md +0 -19
- package/ai-agent-for-release-notes/docker-compose.yml +0 -18
- package/ai-agent-for-release-notes/dome.tf +0 -165
- package/ai-agent-for-release-notes/guards/block-leaked-keys.json +0 -38
- package/ai-agent-for-release-notes/rules/release-notes.cedar +0 -49
- package/ai-agent-for-release-notes/template.yaml +0 -155
package/README.md
CHANGED
|
@@ -1,168 +1,59 @@
|
|
|
1
|
-
# Dome
|
|
1
|
+
# Dome Templates CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
##
|
|
7
|
+
## Install and run
|
|
7
8
|
|
|
8
|
-
|
|
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
|
-
#
|
|
12
|
+
# Download a template into ./ai-agent-for-documentation.
|
|
15
13
|
npx @domesystems/templates init ai-agent-for-documentation
|
|
16
14
|
|
|
17
|
-
#
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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/bin/dome-templates.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domesystems/templates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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,15 +1,29 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
5
|
import { readManifest } from "./manifest.mjs";
|
|
6
6
|
import { imageForRuntime, renderCompose } from "./runtime.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { downloadTemplate, initializeEnvironment } from "./template-files.mjs";
|
|
8
8
|
|
|
9
|
-
const
|
|
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
|
+
}
|
|
10
24
|
|
|
11
25
|
function usage() {
|
|
12
|
-
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
|
|
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`;
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
function fail(message) {
|
|
@@ -19,13 +33,21 @@ function fail(message) {
|
|
|
19
33
|
function parseArguments(argv) {
|
|
20
34
|
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
21
35
|
const [command, slug, ...rest] = argv;
|
|
22
|
-
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 };
|
|
23
37
|
for (let index = 0; index < rest.length; index += 1) {
|
|
24
38
|
const argument = rest[index];
|
|
25
39
|
if (argument === "--force") options.force = true;
|
|
26
40
|
else if (argument === "--skip-provision") options.provision = false;
|
|
41
|
+
else if (argument === "--provision") options.forceProvision = true;
|
|
27
42
|
else if (argument === "--no-start") options.start = false;
|
|
28
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
|
+
}
|
|
29
51
|
else if (argument === "--dir") {
|
|
30
52
|
options.directory = rest[index + 1];
|
|
31
53
|
if (!options.directory) fail("--dir requires a directory.");
|
|
@@ -39,15 +61,10 @@ function parseArguments(argv) {
|
|
|
39
61
|
return { command, slug, options };
|
|
40
62
|
}
|
|
41
63
|
|
|
42
|
-
function
|
|
64
|
+
function validateTemplateSlug(slug) {
|
|
43
65
|
if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
|
|
44
66
|
fail(`Invalid template slug: ${slug}`);
|
|
45
67
|
}
|
|
46
|
-
const source = path.join(packageRoot, slug);
|
|
47
|
-
if (!fs.statSync(path.join(source, "template.yaml"), { throwIfNoEntry: false })?.isFile()) {
|
|
48
|
-
fail(`Unknown template: ${slug}`);
|
|
49
|
-
}
|
|
50
|
-
return source;
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
function targetDirectory(slug, directory) {
|
|
@@ -58,23 +75,39 @@ function isEmpty(directory) {
|
|
|
58
75
|
return !fs.existsSync(directory) || fs.readdirSync(directory).length === 0;
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
function init(slug, options, output) {
|
|
62
|
-
|
|
78
|
+
async function init(slug, options, output, fetchImplementation) {
|
|
79
|
+
validateTemplateSlug(slug);
|
|
63
80
|
const target = targetDirectory(slug, options.directory);
|
|
64
81
|
if (!isEmpty(target) && !options.force) {
|
|
65
82
|
throw new Error(`${target} already exists and is not empty. Choose --dir or pass --force.`);
|
|
66
83
|
}
|
|
67
|
-
|
|
84
|
+
output(status("info", `Downloading ${slug}…`));
|
|
85
|
+
const archiveURL = await downloadTemplate(slug, target, {
|
|
86
|
+
baseURL: process.env.DOME_TEMPLATES_BASE_URL,
|
|
87
|
+
fetchImplementation,
|
|
88
|
+
});
|
|
68
89
|
const environmentCreated = initializeEnvironment(target);
|
|
69
|
-
output(`
|
|
70
|
-
if (environmentCreated)
|
|
90
|
+
output(status("success", `Downloaded ${slug} from ${archiveURL}.`));
|
|
91
|
+
if (environmentCreated) {
|
|
92
|
+
output("Created .env from .env.example.");
|
|
93
|
+
output("When you run `up`, Dome will prompt for import credentials; they are never written to .env.");
|
|
94
|
+
output("After import, `up` writes DOME_TOKEN and DOME_GATEWAY_URL into .env. Add any other template-specific runtime values before starting.");
|
|
95
|
+
}
|
|
71
96
|
return target;
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
function resolveUpDirectory(slug, options, output) {
|
|
99
|
+
async function resolveUpDirectory(slug, options, output, fetchImplementation) {
|
|
75
100
|
const target = targetDirectory(slug, options.directory);
|
|
76
|
-
if (fs.statSync(path.join(target, "template.yaml"), { throwIfNoEntry: false })?.isFile())
|
|
77
|
-
|
|
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);
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
function run(command, args, cwd) {
|
|
@@ -83,7 +116,150 @@ function run(command, args, cwd) {
|
|
|
83
116
|
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}.`);
|
|
84
117
|
}
|
|
85
118
|
|
|
86
|
-
|
|
119
|
+
function parseFinalJSON(output, command) {
|
|
120
|
+
const starts = [...output.matchAll(/(?:^|\n)\{/g)].map((match) => match.index + (match[0][0] === "\n" ? 1 : 0));
|
|
121
|
+
for (const start of starts.reverse()) {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(output.slice(start));
|
|
124
|
+
} catch {
|
|
125
|
+
// The CLI may have printed an interactive prompt before its final JSON result.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`${command} completed without a readable JSON result.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function runJSON(command, args, cwd, { displayOutput = true } = {}) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn(command, args, { cwd, stdio: ["inherit", "pipe", "inherit"] });
|
|
134
|
+
let output = "";
|
|
135
|
+
let displayed = 0;
|
|
136
|
+
child.stdout.on("data", (chunk) => {
|
|
137
|
+
const text = chunk.toString();
|
|
138
|
+
output += 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
|
+
}
|
|
153
|
+
});
|
|
154
|
+
child.on("error", (error) => reject(new Error(`Could not run ${command}: ${error.message}`)));
|
|
155
|
+
child.on("close", (status) => {
|
|
156
|
+
if (status !== 0) {
|
|
157
|
+
reject(new Error(`${command} ${args.join(" ")} exited with status ${status}.`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
resolve(parseFinalJSON(output, command));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
reject(error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
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
|
+
|
|
178
|
+
function replaceEnvironmentValue(source, name, value) {
|
|
179
|
+
const expression = new RegExp(`^${name}=.*$`, "m");
|
|
180
|
+
if (!expression.test(source)) throw new Error(`The runtime .env is missing ${name}.`);
|
|
181
|
+
return source.replace(expression, `${name}=${JSON.stringify(value)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function undefinedEnvironmentValues(source) {
|
|
185
|
+
return source
|
|
186
|
+
.split(/\r?\n/)
|
|
187
|
+
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(?:\s*|""|''|undefined)$/)?.[1])
|
|
188
|
+
.filter(Boolean);
|
|
189
|
+
}
|
|
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
|
+
|
|
201
|
+
async function fillRuntimeEnvironment(target, output) {
|
|
202
|
+
const environmentPath = path.join(target, ".env");
|
|
203
|
+
let environment = fs.readFileSync(environmentPath, "utf8");
|
|
204
|
+
const missing = undefinedEnvironmentValues(environment);
|
|
205
|
+
if (missing.length === 0) return;
|
|
206
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
207
|
+
throw new Error(`Missing runtime .env values: ${missing.join(", ")}. Set them before starting Docker.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
output(`Complete ${environmentPath} before starting the app.`);
|
|
211
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
212
|
+
const write = terminal._writeToOutput.bind(terminal);
|
|
213
|
+
terminal._writeToOutput = (text) => {
|
|
214
|
+
if (text === "\n" || text === "\r\n") write(text);
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
for (const name of missing) {
|
|
218
|
+
const value = (await terminal.question(`Enter ${name}: `)).trim();
|
|
219
|
+
if (!value) throw new Error(`${name} is required to start the app.`);
|
|
220
|
+
environment = replaceEnvironmentValue(environment, name, value);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
terminal.close();
|
|
224
|
+
}
|
|
225
|
+
fs.writeFileSync(environmentPath, environment);
|
|
226
|
+
output(`Saved runtime values to ${environmentPath}.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function gatewayURL(response) {
|
|
230
|
+
return response.data?.gateway?.endpoints?.gatewayUrl ?? response.data?.gateway?.endpoints?.gateway_url;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function writeRuntimeEnvironment(target, manifest, importResponse, output) {
|
|
234
|
+
const jobID = importResponse.data?.job?.id;
|
|
235
|
+
if (!jobID) throw new Error("Dome import succeeded but did not return an import job ID.");
|
|
236
|
+
|
|
237
|
+
const gatewayName = manifest.catalog?.gateway?.name;
|
|
238
|
+
if (!gatewayName) throw new Error("template.yaml must declare catalog.gateway.name to configure the runtime.");
|
|
239
|
+
|
|
240
|
+
const environmentPath = path.join(target, ".env");
|
|
241
|
+
let environment = fs.readFileSync(environmentPath, "utf8");
|
|
242
|
+
// Validate the local contract before consuming a one-time secret.
|
|
243
|
+
for (const name of ["DOME_TOKEN", "DOME_GATEWAY_URL"]) {
|
|
244
|
+
if (!new RegExp(`^${name}=.*$`, "m").test(environment)) throw new Error(`The runtime .env is missing ${name}.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const gateway = await runJSON("dome", ["gateways", "get", gatewayName, "--format", "json"], target, { displayOutput: false });
|
|
248
|
+
const url = gatewayURL(gateway);
|
|
249
|
+
if (!url) throw new Error(`Gateway ${gatewayName} has no runtime endpoint.`);
|
|
250
|
+
|
|
251
|
+
const revealed = await runJSON("dome", ["import", "outputs", jobID, "--format", "json"], target, { displayOutput: false });
|
|
252
|
+
const outputs = revealed.data?.outputs ?? {};
|
|
253
|
+
const tokens = Object.entries(outputs).filter(([name, value]) => name.endsWith("_token") && value);
|
|
254
|
+
if (tokens.length !== 1) throw new Error("The import must produce exactly one *_token output to configure DOME_TOKEN.");
|
|
255
|
+
|
|
256
|
+
environment = replaceEnvironmentValue(environment, "DOME_TOKEN", tokens[0][1]);
|
|
257
|
+
environment = replaceEnvironmentValue(environment, "DOME_GATEWAY_URL", url);
|
|
258
|
+
fs.writeFileSync(environmentPath, environment);
|
|
259
|
+
output(`Configured ${environmentPath} with the one-time agent token and Gateway URL.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function main(argv, { output = console.log, error = console.error, fetchImplementation = globalThis.fetch } = {}) {
|
|
87
263
|
try {
|
|
88
264
|
const parsed = parseArguments(argv);
|
|
89
265
|
if (parsed.help) {
|
|
@@ -92,18 +268,35 @@ export function main(argv, { output = console.log, error = console.error } = {})
|
|
|
92
268
|
}
|
|
93
269
|
const { command, slug, options } = parsed;
|
|
94
270
|
if (command === "init") {
|
|
95
|
-
init(slug, options, output);
|
|
271
|
+
await init(slug, options, output, fetchImplementation);
|
|
96
272
|
return;
|
|
97
273
|
}
|
|
98
274
|
|
|
99
|
-
const target = resolveUpDirectory(slug, options, output);
|
|
275
|
+
const target = await resolveUpDirectory(slug, options, output, fetchImplementation);
|
|
100
276
|
const manifest = readManifest(target);
|
|
101
277
|
const composePath = path.join(target, ".dome-compose.yaml");
|
|
102
|
-
fs.writeFileSync(composePath, renderCompose(manifest.app.runtime));
|
|
103
|
-
|
|
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
|
+
}
|
|
104
282
|
|
|
105
|
-
|
|
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)));
|
|
295
|
+
await writeRuntimeEnvironment(target, manifest, imported, output);
|
|
296
|
+
}
|
|
106
297
|
if (options.start) {
|
|
298
|
+
await fillRuntimeEnvironment(target, output);
|
|
299
|
+
output(status("info", `Starting app at http://localhost:${options.port}…`));
|
|
107
300
|
const args = ["compose", "-f", ".dome-compose.yaml", "up"];
|
|
108
301
|
if (options.detach) args.push("--detach");
|
|
109
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 - \"
|
|
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
|
}
|
package/src/template-files.mjs
CHANGED
|
@@ -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
|
+
}
|