@domesystems/templates 0.1.1 → 0.3.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 +16 -158
- package/package.json +8 -5
- package/src/cli.mjs +46 -232
- package/src/manifest.mjs +33 -0
- package/src/publish-files.mjs +68 -0
- package/src/registry.mjs +100 -0
- package/src/runtime.mjs +2 -2
- package/src/template-files.mjs +54 -5
- 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 -131
- 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 -143
- 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 -156
package/README.md
CHANGED
|
@@ -1,168 +1,26 @@
|
|
|
1
|
-
# Dome
|
|
1
|
+
# Dome Templates CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
configuration bundle for Dome with an optional companion application.
|
|
5
|
-
|
|
6
|
-
## Use the template CLI
|
|
7
|
-
|
|
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.
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
# Create a working copy in ./ai-agent-for-documentation.
|
|
15
|
-
npx @domesystems/templates init ai-agent-for-documentation
|
|
16
|
-
|
|
17
|
-
# Initialize when needed, provision Dome, then start the selected runtime.
|
|
18
|
-
npx @domesystems/templates up ai-agent-for-documentation
|
|
19
|
-
```
|
|
20
|
-
|
|
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.
|
|
26
|
-
|
|
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
|
|
3
|
+
Pull immutable Dome template releases, publish public templates from GitHub, and run an existing local directory.
|
|
44
4
|
|
|
45
5
|
```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
|
|
6
|
+
npx @domesystems/templates login --github
|
|
7
|
+
npx @domesystems/templates pull dome-systems/ai-agent-for-documentation --dir ai-agent-for-documentation
|
|
8
|
+
npx @domesystems/templates up --dir ai-agent-for-documentation
|
|
58
9
|
```
|
|
59
10
|
|
|
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.
|
|
11
|
+
Every remote command takes `--server <Dome application URL>`. The selected app server discovers its Registry endpoint; the CLI never derives one by rewriting hostnames. Credentials are isolated per server profile.
|
|
64
12
|
|
|
65
|
-
|
|
13
|
+
## Commands
|
|
66
14
|
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 .
|
|
15
|
+
```text
|
|
16
|
+
templates login [--server <URL>] [--github]
|
|
17
|
+
templates logout [--server <URL>]
|
|
18
|
+
templates doctor <pull|up|publish> [--dir <directory>] [--server <URL>] [--json]
|
|
19
|
+
templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>]
|
|
20
|
+
templates up [--dir <directory>] [--port <host-port>] [--skip-provision|--provision] [--no-start] [--detach]
|
|
21
|
+
templates publish [--dry-run] [--server <URL>]
|
|
85
22
|
```
|
|
86
23
|
|
|
87
|
-
|
|
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`.
|
|
94
|
-
|
|
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
|
|
165
|
-
```
|
|
24
|
+
`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. `publish` reads the scoped name and SemVer version from `template.yaml`, requires a clean Git checkout, and cannot replace a published version.
|
|
166
25
|
|
|
167
|
-
|
|
168
|
-
be added when CI has an appropriate non-production workspace and credentials.
|
|
26
|
+
The CLI holds no authority over package ownership or artifact access. The Registry revalidates every publish and creates short-lived artifact URLs; it does not expose raw S3 keys.
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@domesystems/templates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Pull, publish, and run versioned Dome templates.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/dome-systems/templates-cli.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"bin": {
|
|
7
11
|
"dome-templates": "bin/dome-templates.mjs"
|
|
@@ -9,9 +13,7 @@
|
|
|
9
13
|
"files": [
|
|
10
14
|
"bin",
|
|
11
15
|
"src",
|
|
12
|
-
"
|
|
13
|
-
"ai-agent-for-notion",
|
|
14
|
-
"ai-agent-for-release-notes"
|
|
16
|
+
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"engines": {
|
|
17
19
|
"node": ">=20"
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
"test": "node --test"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
29
|
+
"unzipper": "0.12.3",
|
|
27
30
|
"yaml": "2.9.0"
|
|
28
31
|
}
|
|
29
32
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,237 +1,51 @@
|
|
|
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
|
-
import { readManifest } from "./manifest.mjs";
|
|
5
|
+
import { readManifest, readPublishManifest } from "./manifest.mjs";
|
|
7
6
|
import { imageForRuntime, renderCompose } from "./runtime.mjs";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function targetDirectory(slug, directory) {
|
|
55
|
-
return path.resolve(process.cwd(), directory ?? slug);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isEmpty(directory) {
|
|
59
|
-
return !fs.existsSync(directory) || fs.readdirSync(directory).length === 0;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function init(slug, options, output) {
|
|
63
|
-
const source = templateSource(slug);
|
|
64
|
-
const target = targetDirectory(slug, options.directory);
|
|
65
|
-
if (!isEmpty(target) && !options.force) {
|
|
66
|
-
throw new Error(`${target} already exists and is not empty. Choose --dir or pass --force.`);
|
|
67
|
-
}
|
|
68
|
-
copyTemplate(source, target);
|
|
69
|
-
const environmentCreated = initializeEnvironment(target);
|
|
70
|
-
output(`Initialized ${slug} in ${target}.`);
|
|
71
|
-
if (environmentCreated) {
|
|
72
|
-
output("Created .env from .env.example.");
|
|
73
|
-
output("When you run `up`, Dome will prompt for import credentials; they are never written to .env.");
|
|
74
|
-
output("After import, `up` writes DOME_TOKEN and DOME_GATEWAY_URL into .env. Add any other template-specific runtime values before starting.");
|
|
75
|
-
}
|
|
76
|
-
return target;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function resolveUpDirectory(slug, options, output) {
|
|
80
|
-
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);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function run(command, args, cwd) {
|
|
86
|
-
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
|
|
87
|
-
if (result.error) throw new Error(`Could not run ${command}: ${result.error.message}`);
|
|
88
|
-
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}.`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function parseFinalJSON(output, command) {
|
|
92
|
-
const starts = [...output.matchAll(/(?:^|\n)\{/g)].map((match) => match.index + (match[0][0] === "\n" ? 1 : 0));
|
|
93
|
-
for (const start of starts.reverse()) {
|
|
94
|
-
try {
|
|
95
|
-
return JSON.parse(output.slice(start));
|
|
96
|
-
} catch {
|
|
97
|
-
// The CLI may have printed an interactive prompt before its final JSON result.
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
throw new Error(`${command} completed without a readable JSON result.`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function runJSON(command, args, cwd, { displayOutput = true } = {}) {
|
|
104
|
-
return new Promise((resolve, reject) => {
|
|
105
|
-
const child = spawn(command, args, { cwd, stdio: ["inherit", "pipe", "inherit"] });
|
|
106
|
-
let output = "";
|
|
107
|
-
child.stdout.on("data", (chunk) => {
|
|
108
|
-
const text = chunk.toString();
|
|
109
|
-
output += text;
|
|
110
|
-
if (displayOutput) process.stdout.write(text);
|
|
111
|
-
});
|
|
112
|
-
child.on("error", (error) => reject(new Error(`Could not run ${command}: ${error.message}`)));
|
|
113
|
-
child.on("close", (status) => {
|
|
114
|
-
if (status !== 0) {
|
|
115
|
-
reject(new Error(`${command} ${args.join(" ")} exited with status ${status}.`));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
try {
|
|
119
|
-
resolve(parseFinalJSON(output, command));
|
|
120
|
-
} catch (error) {
|
|
121
|
-
reject(error);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function replaceEnvironmentValue(source, name, value) {
|
|
128
|
-
const expression = new RegExp(`^${name}=.*$`, "m");
|
|
129
|
-
if (!expression.test(source)) throw new Error(`The runtime .env is missing ${name}.`);
|
|
130
|
-
return source.replace(expression, `${name}=${JSON.stringify(value)}`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function undefinedEnvironmentValues(source) {
|
|
134
|
-
return source
|
|
135
|
-
.split(/\r?\n/)
|
|
136
|
-
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(?:\s*|""|''|undefined)$/)?.[1])
|
|
137
|
-
.filter(Boolean);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function fillRuntimeEnvironment(target, output) {
|
|
141
|
-
const environmentPath = path.join(target, ".env");
|
|
142
|
-
let environment = fs.readFileSync(environmentPath, "utf8");
|
|
143
|
-
const missing = undefinedEnvironmentValues(environment);
|
|
144
|
-
if (missing.length === 0) return;
|
|
145
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
146
|
-
throw new Error(`Missing runtime .env values: ${missing.join(", ")}. Set them before starting Docker.`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
output(`Complete ${environmentPath} before starting the app.`);
|
|
150
|
-
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
151
|
-
const write = terminal._writeToOutput.bind(terminal);
|
|
152
|
-
terminal._writeToOutput = (text) => {
|
|
153
|
-
if (text === "\n" || text === "\r\n") write(text);
|
|
154
|
-
};
|
|
155
|
-
try {
|
|
156
|
-
for (const name of missing) {
|
|
157
|
-
const value = (await terminal.question(`Enter ${name}: `)).trim();
|
|
158
|
-
if (!value) throw new Error(`${name} is required to start the app.`);
|
|
159
|
-
environment = replaceEnvironmentValue(environment, name, value);
|
|
160
|
-
}
|
|
161
|
-
} finally {
|
|
162
|
-
terminal.close();
|
|
163
|
-
}
|
|
164
|
-
fs.writeFileSync(environmentPath, environment);
|
|
165
|
-
output(`Saved runtime values to ${environmentPath}.`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function gatewayURL(response) {
|
|
169
|
-
return response.data?.gateway?.endpoints?.gatewayUrl ?? response.data?.gateway?.endpoints?.gateway_url;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function writeRuntimeEnvironment(target, manifest, importResponse, output) {
|
|
173
|
-
const jobID = importResponse.data?.job?.id;
|
|
174
|
-
if (!jobID) throw new Error("Dome import succeeded but did not return an import job ID.");
|
|
175
|
-
|
|
176
|
-
const gatewayName = manifest.catalog?.gateway?.name;
|
|
177
|
-
if (!gatewayName) throw new Error("template.yaml must declare catalog.gateway.name to configure the runtime.");
|
|
178
|
-
|
|
179
|
-
const environmentPath = path.join(target, ".env");
|
|
180
|
-
let environment = fs.readFileSync(environmentPath, "utf8");
|
|
181
|
-
// Validate the local contract before consuming a one-time secret.
|
|
182
|
-
for (const name of ["DOME_TOKEN", "DOME_GATEWAY_URL"]) {
|
|
183
|
-
if (!new RegExp(`^${name}=.*$`, "m").test(environment)) throw new Error(`The runtime .env is missing ${name}.`);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const gateway = await runJSON("dome", ["gateways", "get", gatewayName, "--format", "json"], target, { displayOutput: false });
|
|
187
|
-
const url = gatewayURL(gateway);
|
|
188
|
-
if (!url) throw new Error(`Gateway ${gatewayName} has no runtime endpoint.`);
|
|
189
|
-
|
|
190
|
-
const revealed = await runJSON("dome", ["import", "outputs", jobID, "--format", "json"], target, { displayOutput: false });
|
|
191
|
-
const outputs = revealed.data?.outputs ?? {};
|
|
192
|
-
const tokens = Object.entries(outputs).filter(([name, value]) => name.endsWith("_token") && value);
|
|
193
|
-
if (tokens.length !== 1) throw new Error("The import must produce exactly one *_token output to configure DOME_TOKEN.");
|
|
194
|
-
|
|
195
|
-
environment = replaceEnvironmentValue(environment, "DOME_TOKEN", tokens[0][1]);
|
|
196
|
-
environment = replaceEnvironmentValue(environment, "DOME_GATEWAY_URL", url);
|
|
197
|
-
fs.writeFileSync(environmentPath, environment);
|
|
198
|
-
output(`Configured ${environmentPath} with the one-time agent token and Gateway URL.`);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export async function main(argv, { output = console.log, error = console.error } = {}) {
|
|
202
|
-
try {
|
|
203
|
-
const parsed = parseArguments(argv);
|
|
204
|
-
if (parsed.help) {
|
|
205
|
-
output(usage());
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const { command, slug, options } = parsed;
|
|
209
|
-
if (command === "init") {
|
|
210
|
-
init(slug, options, output);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const target = resolveUpDirectory(slug, options, output);
|
|
215
|
-
const manifest = readManifest(target);
|
|
216
|
-
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)}.`);
|
|
219
|
-
|
|
220
|
-
if (options.provision) {
|
|
221
|
-
const imported = await runJSON("dome", ["import", "dome.tf", "--format", "json"], target);
|
|
222
|
-
await writeRuntimeEnvironment(target, manifest, imported, output);
|
|
223
|
-
}
|
|
224
|
-
if (options.start) {
|
|
225
|
-
await fillRuntimeEnvironment(target, output);
|
|
226
|
-
const args = ["compose", "-f", ".dome-compose.yaml", "up"];
|
|
227
|
-
if (options.detach) args.push("--detach");
|
|
228
|
-
run("docker", args, target);
|
|
229
|
-
}
|
|
230
|
-
if (!options.provision || !options.start) {
|
|
231
|
-
output("Skipped requested steps. Re-run without --skip-provision or --no-start when ready.");
|
|
232
|
-
}
|
|
233
|
-
} catch (caught) {
|
|
234
|
-
error(`Error: ${caught.message}`);
|
|
235
|
-
process.exitCode = 1;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
7
|
+
import { extractRelease, initializeEnvironment } from "./template-files.mjs";
|
|
8
|
+
import { createArchive, publishSource } from "./publish-files.mjs";
|
|
9
|
+
import { DEFAULT_SERVER, deleteProfile, discover, login, normalizeServer, readProfile, registryRequest } from "./registry.mjs";
|
|
10
|
+
|
|
11
|
+
function ansi(code, value, options) { return options.color && process.stdout.isTTY ? `\u001B[${code}m${value}\u001B[0m` : value; }
|
|
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}`; }
|
|
13
|
+
function usage() { return "Usage:\n templates login [--server <Dome application URL>] [--github]\n templates logout [--server <Dome application URL>]\n templates doctor <pull|up|publish> [package] [--dir <directory>] [--server <URL>] [--json]\n templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>]\n templates up [--dir <directory>] [--port <host-port>] [--skip-provision|--provision] [--no-start] [--detach] [--verbose]\n templates publish [--dry-run] [--server <Dome application URL>]\n"; }
|
|
14
|
+
function parse(argv) {
|
|
15
|
+
if (!argv.length || argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
16
|
+
const [command, ...rest] = argv;
|
|
17
|
+
const options = { server: undefined, directory: undefined, port: 3000, dryRun: false, json: false, github: false, provision: true, forceProvision: false, start: true, detach: false, verbose: false, color: !process.env.NO_COLOR };
|
|
18
|
+
const values = [];
|
|
19
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
20
|
+
const arg = rest[i];
|
|
21
|
+
if (arg === "--server" || arg === "--dir" || arg === "--port" || arg === "--color") { const value = rest[++i]; if (!value) throw new Error(`${arg} requires a value.`); if (arg === "--server") options.server = normalizeServer(value); else if (arg === "--dir") options.directory = 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 === "--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);
|
|
23
|
+
}
|
|
24
|
+
if (!["login", "logout", "doctor", "pull", "up", "publish"].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
25
|
+
return { command, values, options };
|
|
26
|
+
}
|
|
27
|
+
function packageReference(value) { const match = /^([a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)(?:@(.+))?$/.exec(value ?? ""); if (!match) throw new Error("Template package must be fully qualified as scope/package or scope/package@version."); return { name: match[1], version: match[2] }; }
|
|
28
|
+
function destination(name, directory) { return path.resolve(process.cwd(), directory ?? name.split("/")[1]); }
|
|
29
|
+
function empty(dir) { return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0; }
|
|
30
|
+
async function pull(name, options, output, fetchImplementation) {
|
|
31
|
+
const ref = packageReference(name); const target = destination(ref.name, options.directory); if (!empty(target)) throw new Error(`${target} already exists and is not empty. Choose an empty --dir.`);
|
|
32
|
+
output(line("info", `Fetching ${ref.name}@${ref.version ?? "latest"}`, options));
|
|
33
|
+
const [scope, slug] = ref.name.split("/"); const { body: release } = await registryRequest(options.server ?? DEFAULT_SERVER, `/v1/packages/${scope}/${slug}${ref.version ? `/${encodeURIComponent(ref.version)}` : ""}`, { fetchImplementation });
|
|
34
|
+
if (!release.name || !release.version || !release.sha256 || !release.source) throw new Error("Registry returned incomplete release metadata.");
|
|
35
|
+
const { body: download } = await registryRequest(options.server ?? DEFAULT_SERVER, `/v1/packages/${scope}/${slug}/${encodeURIComponent(release.version)}/download-url`, { method: "POST", fetchImplementation });
|
|
36
|
+
const response = await fetchImplementation(download.url, { headers: { accept: "application/zip" } }); if (!response.ok) throw new Error(`Could not download ${ref.name}: ${response.status} ${response.statusText}.`);
|
|
37
|
+
fs.mkdirSync(target, { recursive: true }); await extractRelease(Buffer.from(await response.arrayBuffer()), target, release); const created = initializeEnvironment(target);
|
|
38
|
+
output(line("success", `Pulled ${release.name}@${release.version}`, options)); output(` Saved to ${target}`); if (created) output(line("success", "Created .env from .env.example", options));
|
|
39
|
+
}
|
|
40
|
+
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.`); }
|
|
41
|
+
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"))); }
|
|
42
|
+
async function up(options, output) { const target = path.resolve(process.cwd(), options.directory ?? "."); const manifest = readManifest(target); output(line("info", `Checking ${target}`, options)); fs.writeFileSync(path.join(target, ".dome-compose.yaml"), renderCompose(manifest.app.runtime, options.port)); if (options.provision && (!configured(target) || options.forceProvision)) { output(line("info", "Setting up Dome resources", options)); run("dome", ["import", "dome.tf"], target); } 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); } }
|
|
43
|
+
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 } };
|
|
44
|
+
const { target: registry, body: attempt } = await registryRequest(options.server ?? DEFAULT_SERVER, "/v1/publish-attempts", { method: "POST", body: { ...payload, dry_run: options.dryRun }, authenticated: true, fetchImplementation });
|
|
45
|
+
if (options.dryRun) { output(line("success", `Publish plan is valid for ${name}@${manifest.metadata.version}`, options)); return; }
|
|
46
|
+
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.server, `/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(); }
|
|
47
|
+
}
|
|
48
|
+
async function doctor(subject, options, output, fetchImplementation) { const checks = []; const add = (name, passed, remediation) => checks.push({ name, passed, remediation }); try { if (subject === "pull") { await discover(options.server ?? DEFAULT_SERVER, 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") { readPublishManifest(path.resolve(process.cwd(), options.directory ?? ".")); publishSource(path.resolve(process.cwd(), options.directory ?? ".")); const profile = readProfile(options.server ?? DEFAULT_SERVER); add("Registry sign-in", Boolean(profile?.token), `Run npx @domesystems/templates login --server ${options.server ?? DEFAULT_SERVER}`); } else throw new Error("doctor expects pull, up, or publish."); } catch (error) { add("Preflight", false, error.message); }
|
|
49
|
+
if (options.json) output(JSON.stringify({ server: options.server ?? DEFAULT_SERVER, 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.`);
|
|
50
|
+
}
|
|
51
|
+
export async function main(argv, { output = console.log, error = console.error, fetchImplementation = globalThis.fetch, sleep } = {}) { try { const parsed = parse(argv); if (parsed.help) return output(usage()); const { command, values, options } = parsed; if (options.json) options.color = false; if (command === "login") { output(line("info", `Opening sign-in for ${options.server ?? DEFAULT_SERVER}`, options)); const session = await login(options.server ?? DEFAULT_SERVER, { github: options.github, fetchImplementation, sleep, onStart: (started) => output(`Open ${started.verification_uri} and enter ${started.user_code ?? "the displayed code"}.`) }); output(line("success", `Signed in as ${session.session.email}`, options)); return; } if (command === "logout") { const server = options.server ?? DEFAULT_SERVER; const profile = readProfile(server); if (profile?.token) await registryRequest(server, "/v1/auth/logout", { method: "POST", authenticated: true, fetchImplementation }); deleteProfile(server); output(line("success", `Signed out of ${server}`, options)); return; } if (command === "pull") return pull(values[0], options, output, fetchImplementation); if (command === "up") return up(options, output); if (command === "publish") return publish(options, output, fetchImplementation); return doctor(values[0], options, output, fetchImplementation); } catch (caught) { error(line("error", caught.message, { color: false })); process.exitCode = 1; } }
|
package/src/manifest.mjs
CHANGED
|
@@ -20,3 +20,36 @@ export function readManifest(templateDirectory) {
|
|
|
20
20
|
}
|
|
21
21
|
return manifest;
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
25
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
26
|
+
|
|
27
|
+
export function readPublishManifest(templateDirectory) {
|
|
28
|
+
const manifest = readManifest(templateDirectory);
|
|
29
|
+
if (manifest.apiVersion !== "templates.domesystems.ai/v1" || manifest.kind !== "Template") {
|
|
30
|
+
throw new Error("template.yaml must declare apiVersion templates.domesystems.ai/v1 and kind Template.");
|
|
31
|
+
}
|
|
32
|
+
const metadata = manifest.metadata ?? {};
|
|
33
|
+
if (!PACKAGE_NAME.test(metadata.name ?? "")) {
|
|
34
|
+
throw new Error("template.yaml metadata.name must be a lowercase scoped name such as im2nguyen/tinyfish.");
|
|
35
|
+
}
|
|
36
|
+
if (!VERSION.test(metadata.version ?? "")) {
|
|
37
|
+
throw new Error("template.yaml metadata.version must be a valid SemVer version.");
|
|
38
|
+
}
|
|
39
|
+
if (typeof metadata.title !== "string" || !metadata.title.trim()) {
|
|
40
|
+
throw new Error("template.yaml metadata.title is required to publish a template.");
|
|
41
|
+
}
|
|
42
|
+
const source = metadata.source;
|
|
43
|
+
if (!source || typeof source.repository !== "string" || typeof source.path !== "string" || !source.path.trim()) {
|
|
44
|
+
throw new Error("template.yaml metadata.source.repository and metadata.source.path are required to publish a template.");
|
|
45
|
+
}
|
|
46
|
+
let repository;
|
|
47
|
+
try { repository = new URL(source.repository); } catch { throw new Error("metadata.source.repository must be a GitHub HTTPS URL."); }
|
|
48
|
+
if (repository.protocol !== "https:" || repository.hostname !== "github.com" || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(repository.pathname)) {
|
|
49
|
+
throw new Error("metadata.source.repository must be a public https://github.com/<owner>/<repository> URL.");
|
|
50
|
+
}
|
|
51
|
+
if (source.path.split(/[\\/]+/).some((part) => !part || part === "." || part === "..")) {
|
|
52
|
+
throw new Error("metadata.source.path must be a relative repository path without traversal.");
|
|
53
|
+
}
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const EXCLUDED = new Set([".git", ".env", ".terraform", "node_modules", ".dome-compose.yaml", "docker-compose.yml"]);
|
|
8
|
+
const MAX_BYTES = 100 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function walk(root, relative = "") {
|
|
11
|
+
const directory = path.join(root, relative);
|
|
12
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
13
|
+
if (EXCLUDED.has(entry.name)) return [];
|
|
14
|
+
const next = path.posix.join(relative, entry.name);
|
|
15
|
+
if (entry.isDirectory()) return walk(root, next);
|
|
16
|
+
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`Cannot publish unsupported source entry: ${next}`);
|
|
17
|
+
return [next];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function git(command, args, cwd) {
|
|
22
|
+
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
|
|
23
|
+
if (result.error || result.status !== 0) {
|
|
24
|
+
const reason = result.error?.message ?? result.stderr.trim() ?? "command failed";
|
|
25
|
+
throw new Error(`Git is required to publish this template (${reason}).`);
|
|
26
|
+
}
|
|
27
|
+
return result.stdout.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function publishSource(directory) {
|
|
31
|
+
const root = path.resolve(directory);
|
|
32
|
+
const commit = git("git", ["rev-parse", "HEAD"], root);
|
|
33
|
+
if (git("git", ["status", "--porcelain", "--", "."], root)) throw new Error("The template directory has uncommitted changes. Commit or stash them before publishing.");
|
|
34
|
+
const remote = git("git", ["config", "--get", "remote.origin.url"], root).replace(/\.git$/, "");
|
|
35
|
+
const files = walk(root).sort();
|
|
36
|
+
let size = 0;
|
|
37
|
+
const entries = files.map((file) => {
|
|
38
|
+
const contents = fs.readFileSync(path.join(root, file));
|
|
39
|
+
size += contents.length;
|
|
40
|
+
if (size > MAX_BYTES) throw new Error("Template source exceeds the 100 MiB publishing limit.");
|
|
41
|
+
return { path: file, sha256: crypto.createHash("sha256").update(contents).digest("hex"), size: contents.length };
|
|
42
|
+
});
|
|
43
|
+
return { root, commit, remote, files: entries, size };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createArchive(source) {
|
|
47
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "dome-template-publish-"));
|
|
48
|
+
const archive = path.join(staging, "release.zip");
|
|
49
|
+
const contents = path.join(staging, "template");
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(contents);
|
|
52
|
+
for (const entry of source.files) {
|
|
53
|
+
const destination = path.join(contents, entry.path);
|
|
54
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
55
|
+
fs.copyFileSync(path.join(source.root, entry.path), destination);
|
|
56
|
+
}
|
|
57
|
+
const result = spawnSync("zip", ["-q", "-r", archive, "template"], { cwd: staging, encoding: "utf8" });
|
|
58
|
+
if (result.error || result.status !== 0) {
|
|
59
|
+
const reason = result.error?.message ?? result.stderr.trim() ?? "zip failed";
|
|
60
|
+
throw new Error(`Could not package template release (${reason}).`);
|
|
61
|
+
}
|
|
62
|
+
const buffer = fs.readFileSync(archive);
|
|
63
|
+
return { buffer, sha256: crypto.createHash("sha256").update(buffer).digest("hex"), cleanup: () => fs.rmSync(staging, { recursive: true, force: true }) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|