@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.
Files changed (36) hide show
  1. package/README.md +16 -158
  2. package/package.json +8 -5
  3. package/src/cli.mjs +46 -232
  4. package/src/manifest.mjs +33 -0
  5. package/src/publish-files.mjs +68 -0
  6. package/src/registry.mjs +100 -0
  7. package/src/runtime.mjs +2 -2
  8. package/src/template-files.mjs +54 -5
  9. package/ai-agent-for-documentation/.env.example +0 -12
  10. package/ai-agent-for-documentation/app/agent.yaml +0 -31
  11. package/ai-agent-for-documentation/app/help.md +0 -19
  12. package/ai-agent-for-documentation/app/instructions.md +0 -21
  13. package/ai-agent-for-documentation/docker-compose.yml +0 -18
  14. package/ai-agent-for-documentation/dome.tf +0 -146
  15. package/ai-agent-for-documentation/rules/answerer.cedar +0 -35
  16. package/ai-agent-for-documentation/template.yaml +0 -131
  17. package/ai-agent-for-documentation/verification.json +0 -7
  18. package/ai-agent-for-notion/.env.example +0 -19
  19. package/ai-agent-for-notion/app/agent.yaml +0 -32
  20. package/ai-agent-for-notion/app/help.md +0 -14
  21. package/ai-agent-for-notion/app/instructions.md +0 -16
  22. package/ai-agent-for-notion/docker-compose.yml +0 -18
  23. package/ai-agent-for-notion/dome.tf +0 -163
  24. package/ai-agent-for-notion/guards/redact-ssn-from-pages.json +0 -22
  25. package/ai-agent-for-notion/rules/answerer.cedar +0 -29
  26. package/ai-agent-for-notion/template.yaml +0 -143
  27. package/ai-agent-for-notion/verification.json +0 -7
  28. package/ai-agent-for-release-notes/.env.example +0 -15
  29. package/ai-agent-for-release-notes/app/agent.yaml +0 -36
  30. package/ai-agent-for-release-notes/app/help.md +0 -20
  31. package/ai-agent-for-release-notes/app/instructions.md +0 -19
  32. package/ai-agent-for-release-notes/docker-compose.yml +0 -18
  33. package/ai-agent-for-release-notes/dome.tf +0 -165
  34. package/ai-agent-for-release-notes/guards/block-leaked-keys.json +0 -38
  35. package/ai-agent-for-release-notes/rules/release-notes.cedar +0 -49
  36. package/ai-agent-for-release-notes/template.yaml +0 -156
package/README.md CHANGED
@@ -1,168 +1,26 @@
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.
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
- 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
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
- 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.
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
- Every built companion app needs the one-time agent token created by the import:
13
+ ## Commands
66
14
 
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 .
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
- 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`.
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
- 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.
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.1.1",
4
- "description": "Initialize and run Dome governed-agent templates.",
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
- "ai-agent-for-documentation",
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 { copyTemplate, initializeEnvironment } from "./template-files.mjs";
9
-
10
- const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
-
12
- 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`;
14
- }
15
-
16
- function fail(message) {
17
- throw new Error(`${message}\n\n${usage()}`);
18
- }
19
-
20
- function parseArguments(argv) {
21
- if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { help: true };
22
- const [command, slug, ...rest] = argv;
23
- const options = { force: false, provision: true, start: true, detach: false, directory: undefined };
24
- for (let index = 0; index < rest.length; index += 1) {
25
- const argument = rest[index];
26
- if (argument === "--force") options.force = true;
27
- else if (argument === "--skip-provision") options.provision = false;
28
- else if (argument === "--no-start") options.start = false;
29
- else if (argument === "--detach") options.detach = true;
30
- else if (argument === "--dir") {
31
- options.directory = rest[index + 1];
32
- if (!options.directory) fail("--dir requires a directory.");
33
- index += 1;
34
- } else {
35
- fail(`Unknown option: ${argument}`);
36
- }
37
- }
38
- if (!["init", "up"].includes(command)) fail(`Unknown command: ${command}`);
39
- if (!slug) fail("A template slug is required.");
40
- return { command, slug, options };
41
- }
42
-
43
- function templateSource(slug) {
44
- if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
45
- fail(`Invalid template slug: ${slug}`);
46
- }
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
- }
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
+ }