@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 +21 -43
- package/package.json +6 -3
- package/src/cli.mjs +48 -306
- package/src/manifest.mjs +33 -0
- package/src/publish-files.mjs +68 -0
- package/src/registry.mjs +109 -0
- package/src/template-files.mjs +30 -53
package/README.md
CHANGED
|
@@ -1,59 +1,37 @@
|
|
|
1
1
|
# Dome Templates CLI
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
13
|
-
npx @domesystems/templates
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
Use direct Registry endpoints when the app-server discovery document is unavailable:
|
|
23
14
|
|
|
24
15
|
```bash
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const [
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|
package/src/registry.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/template-files.mjs
CHANGED
|
@@ -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
|
|
13
|
-
if (!
|
|
14
|
-
|
|
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
|
|
19
|
+
function relativeArchivePath(entryPath) {
|
|
20
20
|
const normalized = archivePath(entryPath);
|
|
21
|
-
if (normalized ===
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
return archiveURL.toString();
|
|
75
|
+
writeLock(destination, release);
|
|
76
|
+
} finally { fs.rmSync(staging, { recursive: true, force: true }); }
|
|
100
77
|
}
|