@domesystems/templates 0.2.0 → 0.3.1

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 CHANGED
@@ -1,59 +1,37 @@
1
1
  # Dome Templates CLI
2
2
 
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`.
6
-
7
- ## Install and run
8
-
9
- Use `npx`; Node.js 20 or newer is required.
3
+ Pull immutable Dome template releases, publish public templates from GitHub, and run an existing local directory.
10
4
 
11
5
  ```bash
12
- # Download a template into ./ai-agent-for-documentation.
13
- npx @domesystems/templates init ai-agent-for-documentation
14
-
15
- # Download if needed, provision Dome, and run it locally.
16
- npx @domesystems/templates up ai-agent-for-documentation
6
+ npx @domesystems/templates login --registry https://templates-registry.domesystems.ai --github
7
+ npx @domesystems/templates pull dome-systems/ai-agent-for-documentation --registry https://templates-registry.domesystems.ai --dir ai-agent-for-documentation
8
+ npx @domesystems/templates up --dir ai-agent-for-documentation
17
9
  ```
18
10
 
19
- By default, archives come from
20
- `https://templates.domesystems.ai/templates/downloads/<template>.zip`.
11
+ Every remote command takes either `--server <Dome application URL>` or `--registry <Registry URL>`. `--server` discovers the Registry endpoint through the app server; `--registry` connects directly to an explicitly supplied Registry. Credentials are isolated per selected endpoint.
21
12
 
22
- For local Library development, point the CLI at a local download server:
13
+ Use direct Registry endpoints when the app-server discovery document is unavailable:
23
14
 
24
15
  ```bash
25
- DOME_TEMPLATES_BASE_URL=http://localhost:3001/templates/downloads \
26
- npx @domesystems/templates init ai-agent-for-documentation
16
+ # Production
17
+ npx @domesystems/templates login --registry https://templates-registry.domesystems.ai
18
+ npx @domesystems/templates pull dome-systems/ai-agent-for-documentation --registry https://templates-registry.domesystems.ai
19
+
20
+ # Development
21
+ npx @domesystems/templates login --registry https://templates-registry-dev.domesystems.ai
27
22
  ```
28
23
 
29
24
  ## Commands
30
25
 
31
26
  ```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]
27
+ templates login [--server <URL>|--registry <URL>] [--github]
28
+ templates logout [--server <URL>|--registry <URL>]
29
+ templates doctor <pull|up|publish> [--dir <directory>] [--server <URL>|--registry <URL>] [--json]
30
+ templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>|--registry <URL>]
31
+ templates up [--dir <directory>] [--port <host-port>] [--skip-provision|--provision] [--no-start] [--detach]
32
+ templates publish [--dry-run] [--server <URL>|--registry <URL>]
34
33
  ```
35
34
 
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.
35
+ `pull` only accepts scoped package names, verifies the Registry SHA-256 before extraction, accepts only the archive's `template/` root, writes `.dome-template-lock.yaml`, and never overwrites a non-empty directory. `up` is local-only and never downloads or updates a release. `publish` reads the scoped name and SemVer version from `template.yaml`, requires a clean Git checkout, and cannot replace a published version.
36
+
37
+ 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.2.0",
4
- "description": "Download, initialize, and run Dome governed-agent templates.",
3
+ "version": "0.3.1",
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"
@@ -22,7 +26,6 @@
22
26
  "test": "node --test"
23
27
  },
24
28
  "dependencies": {
25
- "unzipper": "0.12.3",
26
29
  "unzipper": "0.12.3",
27
30
  "yaml": "2.9.0"
28
31
  }
package/src/cli.mjs CHANGED
@@ -2,310 +2,52 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import readline from "node:readline/promises";
5
- import { readManifest } from "./manifest.mjs";
5
+ import { readManifest, readPublishManifest } from "./manifest.mjs";
6
6
  import { imageForRuntime, renderCompose } from "./runtime.mjs";
7
- import { downloadTemplate, initializeEnvironment } from "./template-files.mjs";
8
-
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
- }
24
-
25
- function usage() {
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`;
27
- }
28
-
29
- function fail(message) {
30
- throw new Error(`${message}\n\n${usage()}`);
31
- }
32
-
33
- function parseArguments(argv) {
34
- if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { help: true };
35
- const [command, slug, ...rest] = argv;
36
- const options = { force: false, provision: true, forceProvision: false, start: true, detach: false, verbose: false, directory: undefined, port: 3000 };
37
- for (let index = 0; index < rest.length; index += 1) {
38
- const argument = rest[index];
39
- if (argument === "--force") options.force = true;
40
- else if (argument === "--skip-provision") options.provision = false;
41
- else if (argument === "--provision") options.forceProvision = true;
42
- else if (argument === "--no-start") options.start = false;
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
- }
51
- else if (argument === "--dir") {
52
- options.directory = rest[index + 1];
53
- if (!options.directory) fail("--dir requires a directory.");
54
- index += 1;
55
- } else {
56
- fail(`Unknown option: ${argument}`);
57
- }
58
- }
59
- if (!["init", "up"].includes(command)) fail(`Unknown command: ${command}`);
60
- if (!slug) fail("A template slug is required.");
61
- return { command, slug, options };
62
- }
63
-
64
- function validateTemplateSlug(slug) {
65
- if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
66
- fail(`Invalid template slug: ${slug}`);
67
- }
68
- }
69
-
70
- function targetDirectory(slug, directory) {
71
- return path.resolve(process.cwd(), directory ?? slug);
72
- }
73
-
74
- function isEmpty(directory) {
75
- return !fs.existsSync(directory) || fs.readdirSync(directory).length === 0;
76
- }
77
-
78
- async function init(slug, options, output, fetchImplementation) {
79
- validateTemplateSlug(slug);
80
- const target = targetDirectory(slug, options.directory);
81
- if (!isEmpty(target) && !options.force) {
82
- throw new Error(`${target} already exists and is not empty. Choose --dir or pass --force.`);
83
- }
84
- output(status("info", `Downloading ${slug}…`));
85
- const archiveURL = await downloadTemplate(slug, target, {
86
- baseURL: process.env.DOME_TEMPLATES_BASE_URL,
87
- fetchImplementation,
88
- });
89
- const environmentCreated = initializeEnvironment(target);
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
- }
96
- return target;
97
- }
98
-
99
- async function resolveUpDirectory(slug, options, output, fetchImplementation) {
100
- const target = targetDirectory(slug, options.directory);
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);
111
- }
112
-
113
- function run(command, args, cwd) {
114
- const result = spawnSync(command, args, { cwd, stdio: "inherit" });
115
- if (result.error) throw new Error(`Could not run ${command}: ${result.error.message}`);
116
- if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}.`);
117
- }
118
-
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 } = {}) {
263
- try {
264
- const parsed = parseArguments(argv);
265
- if (parsed.help) {
266
- output(usage());
267
- return;
268
- }
269
- const { command, slug, options } = parsed;
270
- if (command === "init") {
271
- await init(slug, options, output, fetchImplementation);
272
- return;
273
- }
274
-
275
- const target = await resolveUpDirectory(slug, options, output, fetchImplementation);
276
- const manifest = readManifest(target);
277
- const composePath = path.join(target, ".dome-compose.yaml");
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
- }
282
-
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
- }
297
- if (options.start) {
298
- await fillRuntimeEnvironment(target, output);
299
- output(status("info", `Starting app at http://localhost:${options.port}…`));
300
- const args = ["compose", "-f", ".dome-compose.yaml", "up"];
301
- if (options.detach) args.push("--detach");
302
- run("docker", args, target);
303
- }
304
- if (!options.provision || !options.start) {
305
- output("Skipped requested steps. Re-run without --skip-provision or --no-start when ready.");
306
- }
307
- } catch (caught) {
308
- error(`Error: ${caught.message}`);
309
- process.exitCode = 1;
310
- }
311
- }
7
+ import { extractRelease, initializeEnvironment } from "./template-files.mjs";
8
+ import { createArchive, publishSource } from "./publish-files.mjs";
9
+ import { DEFAULT_SERVER, deleteProfile, directRegistry, 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>|--registry <Registry URL>] [--github]\n templates logout [--server <Dome application URL>|--registry <Registry URL>]\n templates doctor <pull|up|publish> [package] [--dir <directory>] [--server <URL>|--registry <URL>] [--json]\n templates pull <scope/package[@version]> [--dir <directory>] [--server <URL>|--registry <URL>]\n templates up [--dir <directory>] [--port <host-port>] [--skip-provision|--provision] [--no-start] [--detach] [--verbose]\n templates publish [--dry-run] [--server <Dome application URL>|--registry <Registry 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 === "--registry" || 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 === "--registry") options.registry = 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 (options.server && options.registry) throw new Error("Use either --server or --registry, not both.");
25
+ if (!["login", "logout", "doctor", "pull", "up", "publish"].includes(command)) throw new Error(`Unknown command: ${command}`);
26
+ return { command, values, options };
27
+ }
28
+ function remoteTarget(options) { return options.registry ? directRegistry(options.registry) : options.server ?? DEFAULT_SERVER; }
29
+ 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] }; }
30
+ function destination(name, directory) { return path.resolve(process.cwd(), directory ?? name.split("/")[1]); }
31
+ function empty(dir) { return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0; }
32
+ async function pull(name, options, output, fetchImplementation) {
33
+ 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.`);
34
+ output(line("info", `Fetching ${ref.name}@${ref.version ?? "latest"}`, options));
35
+ const [scope, slug] = ref.name.split("/"); const remote = remoteTarget(options); const { body: release } = await registryRequest(remote, `/v1/packages/${scope}/${slug}${ref.version ? `/${encodeURIComponent(ref.version)}` : ""}`, { fetchImplementation });
36
+ if (!release.name || !release.version || !release.sha256 || !release.source) throw new Error("Registry returned incomplete release metadata.");
37
+ const { body: download } = await registryRequest(remote, `/v1/packages/${scope}/${slug}/${encodeURIComponent(release.version)}/download-url`, { method: "POST", fetchImplementation });
38
+ 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}.`);
39
+ fs.mkdirSync(target, { recursive: true }); await extractRelease(Buffer.from(await response.arrayBuffer()), target, release); const created = initializeEnvironment(target);
40
+ output(line("success", `Pulled ${release.name}@${release.version}`, options)); output(` Saved to ${target}`); if (created) output(line("success", "Created .env from .env.example", options));
41
+ }
42
+ 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.`); }
43
+ 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"))); }
44
+ 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); } }
45
+ 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 } };
46
+ const { target: registry, body: attempt } = await registryRequest(remoteTarget(options), "/v1/publish-attempts", { method: "POST", body: { ...payload, dry_run: options.dryRun }, authenticated: true, fetchImplementation });
47
+ if (options.dryRun) { output(line("success", `Publish plan is valid for ${name}@${manifest.metadata.version}`, options)); return; }
48
+ try { output(line("info", "Uploading release artifact", options)); const upload = await fetchImplementation(attempt.upload_url, { method: "PUT", headers: { "content-type": "application/zip" }, body: archive.buffer }); if (!upload.ok) throw new Error(`Artifact upload failed: ${upload.status} ${upload.statusText}.`); const { body: published } = await registryRequest(registry, `/v1/publish-attempts/${encodeURIComponent(attempt.id)}/complete`, { method: "POST", body: { sha256: archive.sha256, size_bytes: archive.buffer.length }, authenticated: true, fetchImplementation }); output(line("success", `Published ${published.name ?? name}@${published.version ?? manifest.metadata.version}`, options)); if (published.url) output(` ${published.url}`); } finally { archive.cleanup(); }
49
+ }
50
+ async function doctor(subject, options, output, fetchImplementation) { const checks = []; const add = (name, passed, remediation) => checks.push({ name, passed, remediation }); try { if (subject === "pull") { const remote = remoteTarget(options); if (typeof remote === "string") await discover(remote, fetchImplementation); add("Registry reachability", true); } else if (subject === "up") { const target = path.resolve(process.cwd(), options.directory ?? "."); readManifest(target); add("template.yaml", true); add("Docker Compose", spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0, "Install Docker Desktop or Docker Engine with the Compose plugin."); add("Dome CLI", spawnSync("dome", ["--version"], { stdio: "ignore" }).status === 0, "Install the Dome CLI."); } else if (subject === "publish") { readPublishManifest(path.resolve(process.cwd(), options.directory ?? ".")); publishSource(path.resolve(process.cwd(), options.directory ?? ".")); const profile = readProfile(remoteTarget(options).server ?? remoteTarget(options)); const location = options.registry ? `--registry ${options.registry}` : `--server ${options.server ?? DEFAULT_SERVER}`; add("Registry sign-in", Boolean(profile?.token), `Run npx @domesystems/templates login ${location}`); } else throw new Error("doctor expects pull, up, or publish."); } catch (error) { add("Preflight", false, error.message); }
51
+ if (options.json) output(JSON.stringify({ server: options.registry ?? 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.`);
52
+ }
53
+ 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; const remote = remoteTarget(options); if (options.json) options.color = false; if (command === "login") { const destination = options.registry ?? options.server ?? DEFAULT_SERVER; output(line("info", `Opening sign-in for ${destination}`, options)); const session = await login(remote, { 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 = remote.server ?? remote; const profile = readProfile(server); if (profile?.token) await registryRequest(remote, "/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
+ }
@@ -0,0 +1,109 @@
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
+
6
+ export const DEFAULT_SERVER = "https://app.domesystems.ai";
7
+
8
+ function profileKey(server) {
9
+ return crypto.createHash("sha256").update(new URL(server).origin).digest("hex").slice(0, 24);
10
+ }
11
+
12
+ function credentialPath(server) {
13
+ const root = process.env.DOME_TEMPLATES_CONFIG_HOME
14
+ ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "dome", "templates");
15
+ return path.join(root, `${profileKey(server)}.json`);
16
+ }
17
+
18
+ export function normalizeServer(server = DEFAULT_SERVER) {
19
+ const url = new URL(server);
20
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
21
+ throw new Error("--server must use HTTPS (or localhost for development).");
22
+ }
23
+ url.pathname = "/";
24
+ url.search = "";
25
+ url.hash = "";
26
+ return url.toString().replace(/\/$/, "");
27
+ }
28
+
29
+ export function directRegistry(server) {
30
+ const registry = normalizeServer(server);
31
+ return { server: registry, registry };
32
+ }
33
+
34
+ export function readProfile(server) {
35
+ try {
36
+ const profile = JSON.parse(fs.readFileSync(credentialPath(server), "utf8"));
37
+ return profile.server === normalizeServer(server) ? profile : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ export function writeProfile(server, profile) {
42
+ const file = credentialPath(server);
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(file, `${JSON.stringify({ ...profile, server: normalizeServer(server) }, null, 2)}\n`, { mode: 0o600 });
45
+ }
46
+
47
+ export function deleteProfile(server) {
48
+ fs.rmSync(credentialPath(server), { force: true });
49
+ }
50
+
51
+ async function responseJSON(response, action) {
52
+ const type = response.headers?.get?.("content-type") ?? "";
53
+ const body = type.includes("json") ? await response.json().catch(() => null) : null;
54
+ if (!response.ok) throw new Error(body?.message ?? `${action} failed: ${response.status} ${response.statusText}.`);
55
+ return body ?? {};
56
+ }
57
+
58
+ export async function discover(server, fetchImplementation = globalThis.fetch) {
59
+ const selected = normalizeServer(server);
60
+ const response = await fetchImplementation(`${selected}/.well-known/dome-template-registry`, { headers: { accept: "application/json" } });
61
+ const document = await responseJSON(response, "Registry discovery");
62
+ if (typeof document.registry !== "string") throw new Error("The selected Dome server did not return a Registry endpoint.");
63
+ const endpoint = new URL(document.registry);
64
+ if (endpoint.protocol !== "https:" && endpoint.hostname !== "localhost" && endpoint.hostname !== "127.0.0.1") throw new Error("The selected Dome server returned an unsafe Registry endpoint.");
65
+ return { server: selected, registry: endpoint.toString().replace(/\/$/, "") };
66
+ }
67
+
68
+ async function targetFor(server, fetchImplementation) {
69
+ return typeof server === "string" ? discover(server, fetchImplementation) : server;
70
+ }
71
+
72
+ export async function registryRequest(server, pathname, { method = "GET", body, authenticated = false, fetchImplementation = globalThis.fetch } = {}) {
73
+ const target = await targetFor(server, fetchImplementation);
74
+ const headers = { accept: "application/json" };
75
+ if (body !== undefined) headers["content-type"] = "application/json";
76
+ if (authenticated) {
77
+ const profile = readProfile(target.server);
78
+ if (!profile?.token) throw new Error(`Sign in to ${target.server} first: npx @domesystems/templates login --server ${target.server}`);
79
+ headers.authorization = `Bearer ${profile.token}`;
80
+ }
81
+ const response = await fetchImplementation(`${target.registry}${pathname}`, {
82
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body),
83
+ });
84
+ return { target, body: await responseJSON(response, "Registry request") };
85
+ }
86
+
87
+ export async function login(server, { github = false, fetchImplementation = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), onStart } = {}) {
88
+ const target = await targetFor(server, fetchImplementation);
89
+ const response = await fetchImplementation(`${target.registry}/v1/auth/device/start`, {
90
+ method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ github }),
91
+ });
92
+ const started = await responseJSON(response, "Starting sign-in");
93
+ if (!started.device_code || !started.verification_uri) throw new Error("Registry sign-in returned an incomplete device authorization.");
94
+ onStart?.(started);
95
+ const deadline = Date.now() + Math.min(Number(started.expires_in ?? 900), 900) * 1000;
96
+ const interval = Math.max(Number(started.interval ?? 3), 2) * 1000;
97
+ while (Date.now() < deadline) {
98
+ await sleep(interval);
99
+ const complete = await fetchImplementation(`${target.registry}/v1/auth/device/complete`, {
100
+ method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ device_code: started.device_code }),
101
+ });
102
+ if (complete.status === 202 || complete.status === 428) continue;
103
+ const session = await responseJSON(complete, "Completing sign-in");
104
+ if (!session.token || !session.email) throw new Error("Registry sign-in completed without a token or email.");
105
+ writeProfile(target.server, { token: session.token, email: session.email, github: session.github ?? null, publisherScope: session.publisher_scope ?? null });
106
+ return { target, started, session };
107
+ }
108
+ throw new Error("Sign-in expired before it was completed. Run login again.");
109
+ }
@@ -1,7 +1,9 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
- import path from "node:path";
3
3
  import os from "node:os";
4
+ import path from "node:path";
4
5
  import unzipper from "unzipper";
6
+ import { stringify } from "yaml";
5
7
 
6
8
  const excludedNames = new Set([".env", ".git", ".terraform", "node_modules", "docker-compose.yml", ".dome-compose.yaml"]);
7
9
  const maximumArchiveBytes = 50 * 1024 * 1024;
@@ -9,21 +11,16 @@ const maximumExtractedBytes = 100 * 1024 * 1024;
9
11
 
10
12
  function archivePath(relative) {
11
13
  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;
14
+ const clean = normalized.replace(/\/+$/, "");
15
+ if (!clean || normalized.startsWith("/") || normalized.includes("\0") || clean.split("/").some((part) => part === "." || part === "..")) throw new Error(`Template archive contains an unsafe path: ${relative}`);
16
+ return clean;
17
17
  }
18
18
 
19
- function relativeArchivePath(entryPath, slug) {
19
+ function relativeArchivePath(entryPath) {
20
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;
21
+ if (normalized === "template") return undefined;
22
+ if (!normalized.startsWith("template/")) throw new Error("Template archive must contain one fixed template/ root.");
23
+ return normalized.slice("template/".length);
27
24
  }
28
25
 
29
26
  export function copyTemplate(source, destination) {
@@ -32,11 +29,8 @@ export function copyTemplate(source, destination) {
32
29
  if (excludedNames.has(entry.name)) continue;
33
30
  const from = path.join(source, entry.name);
34
31
  const to = path.join(destination, entry.name);
35
- if (entry.isDirectory()) {
36
- copyTemplate(from, to);
37
- } else if (entry.isFile()) {
38
- fs.copyFileSync(from, to);
39
- }
32
+ if (entry.isDirectory()) copyTemplate(from, to);
33
+ else if (entry.isFile()) fs.copyFileSync(from, to);
40
34
  }
41
35
  }
42
36
 
@@ -50,51 +44,34 @@ export function initializeEnvironment(templateDirectory) {
50
44
  return false;
51
45
  }
52
46
 
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.`);
47
+ export function writeLock(destination, release) {
48
+ fs.writeFileSync(path.join(destination, ".dome-template-lock.yaml"), stringify({
49
+ apiVersion: "templates.domesystems.ai/v1", package: release.name, version: release.version, sha256: release.sha256,
50
+ source: { repository: release.source.repository, commit: release.source.commit, path: release.source.path },
51
+ }));
52
+ }
60
53
 
54
+ export async function extractRelease(archive, destination, release) {
55
+ if (!Buffer.isBuffer(archive) || archive.length === 0 || archive.length > maximumArchiveBytes) throw new Error(`Template artifact must be between 1 byte and ${maximumArchiveBytes / 1024 / 1024} MiB.`);
56
+ if (crypto.createHash("sha256").update(archive).digest("hex") !== release.sha256) throw new Error(`Template artifact integrity check failed for ${release.name}@${release.version}.`);
61
57
  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
-
58
+ try { directory = await unzipper.Open.buffer(archive); } catch { throw new Error(`Release artifact for ${release.name} is not a valid ZIP archive.`); }
68
59
  let extractedBytes = 0;
69
- const entries = directory.files
70
- .map((entry) => ({ entry, relative: relativeArchivePath(entry.path, slug) }))
71
- .filter(({ relative }) => relative !== undefined);
60
+ const entries = directory.files.map((entry) => ({ entry, relative: relativeArchivePath(entry.path) })).filter(({ relative }) => relative !== undefined);
72
61
  for (const { entry, relative } of entries) {
73
62
  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
- }
63
+ if (entry.type === "File" && (extractedBytes += entry.uncompressedSize) > maximumExtractedBytes) throw new Error(`Template archive expands beyond ${maximumExtractedBytes / 1024 / 1024} MiB.`);
78
64
  }
79
-
80
65
  const staging = fs.mkdtempSync(path.join(os.tmpdir(), "dome-template-"));
81
66
  try {
82
- const source = path.join(staging, slug);
67
+ const source = path.join(staging, "template");
83
68
  for (const { entry, relative } of entries) {
84
69
  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.`);
70
+ if (entry.type === "Directory") fs.mkdirSync(target, { recursive: true });
71
+ else { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, await entry.buffer()); }
94
72
  }
73
+ if (!fs.statSync(path.join(source, "template.yaml"), { throwIfNoEntry: false })?.isFile()) throw new Error(`Release artifact for ${release.name} does not contain template.yaml.`);
95
74
  copyTemplate(source, destination);
96
- } finally {
97
- fs.rmSync(staging, { recursive: true, force: true });
98
- }
99
- return archiveURL.toString();
75
+ writeLock(destination, release);
76
+ } finally { fs.rmSync(staging, { recursive: true, force: true }); }
100
77
  }