@django-stack/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,360 +1,410 @@
1
1
  #!/usr/bin/env node
2
- import { D as parseEnv, E as checkTools, O as setEnvValues, S as validateDomain, T as runDoctor, _ as encodePreset, a as relativeDirectory, b as stackOption, c as toProjectName, d as CLI_VERSION, f as DEFAULT_STACK, g as decodePreset, h as applies, l as validateProjectName, m as STACK_OPTIONS, n as LOCAL_S3, o as scaffold, p as NAMED_PRESETS, r as lockBackend, s as timeZones, u as validateTimeZone, v as pendingChoices, w as generateSecret, x as productionEnv, y as resolvePreset } from "./scaffold-DbgGYEsc.js";
2
+ import { C as stackOption, D as checkTools, E as runDoctor, O as parseEnv, S as resolvePreset, T as generateSecret, _ as STACK_OPTIONS, a as lockBackend, b as encodePreset, c as scaffold, d as umamiEnv, f as validateProjectName, g as NAMED_PRESETS, h as DEFAULT_STACK, i as LOCAL_S3, k as setEnvValues, l as timeZones, m as CLI_VERSION, n as validateDomain, p as validateTimeZone, s as relativeDirectory, t as productionEnv$1, u as toProjectName, v as applies, w as generateProjectSecrets, x as pendingChoices, y as decodePreset } from "./production-DIj9yRcg.js";
3
3
  import { createRequire } from "node:module";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, delimiter, dirname, join, resolve } from "node:path";
6
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
7
7
  import pc from "picocolors";
8
8
  import tab from "@bomb.sh/tab/commander";
9
9
  import * as p from "@clack/prompts";
10
10
  import { Command, InvalidArgumentError, Option } from "commander";
11
- import { homedir } from "node:os";
12
- // src/deploy/provider.ts
13
- /** The target of a platform with a web API and an API key. */
14
- function urlTarget(name, placeholder) {
11
+ import { homedir, tmpdir } from "node:os";
12
+ // src/deploy/bunny-compose.ts
13
+ /** Volumes that are not needed on Bunny: its CDN holds the certificate, uploads and backups go to S3. */
14
+ const DROPPED_VOLUMES = /* @__PURE__ */ new Set([
15
+ "caddy_data",
16
+ "caddy_config",
17
+ "backups",
18
+ "media"
19
+ ]);
20
+ /** Compose keys that do not apply on Bunny: its gVisor sandbox isolates the app instead. */
21
+ const IGNORED_KEYS = [
22
+ "read_only",
23
+ "tmpfs",
24
+ "cap_add",
25
+ "cap_drop",
26
+ "security_opt",
27
+ "user",
28
+ "deploy",
29
+ "restart",
30
+ "depends_on"
31
+ ];
32
+ /** Images whose port is set with PORT, so a clash with another service can move them. */
33
+ const PORT_ENV_IMAGES = [/(^|\/)umami(-software)?\/umami/i, /^umamisoftware\/umami/i];
34
+ /** Parses a Compose duration such as 5s, 1m0s or 500ms into seconds. */
35
+ function durationSeconds(value, fallback) {
36
+ if (!value) return fallback;
37
+ let total = 0;
38
+ let matched = false;
39
+ for (const [, amount, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ms|us|ns|h|m|s)/g)) {
40
+ matched = true;
41
+ const number = Number(amount);
42
+ total += unit === "h" ? number * 3600 : unit === "m" ? number * 60 : unit === "s" ? number : unit === "ms" ? number / 1e3 : 0;
43
+ }
44
+ return matched ? total : fallback;
45
+ }
46
+ /** Reads a public image reference: postgres:17-alpine, valkey/valkey:8, ghcr.io/owner/name:tag. */
47
+ function parsePublicImage(reference) {
48
+ let rest = reference.trim();
49
+ let digest;
50
+ const at = rest.indexOf("@");
51
+ if (at >= 0) {
52
+ digest = rest.slice(at + 1);
53
+ rest = rest.slice(0, at);
54
+ }
55
+ let source = "dockerHub";
56
+ const slash = rest.indexOf("/");
57
+ const first = slash >= 0 ? rest.slice(0, slash) : "";
58
+ if (first && (first.includes(".") || first.includes(":") || first === "localhost")) {
59
+ if (first === "docker.io" || first === "index.docker.io") source = "dockerHub";
60
+ else if (first === "ghcr.io") source = "gitHub";
61
+ else throw new Error(`Bunny pulls public images from Docker Hub and GitHub only; ${reference} is on ${first}. Push it to one of them, or build it in compose.yaml.`);
62
+ rest = rest.slice(slash + 1);
63
+ }
64
+ const colon = rest.lastIndexOf(":");
65
+ const tag = colon > rest.lastIndexOf("/") ? rest.slice(colon + 1) : "latest";
66
+ const path = colon > rest.lastIndexOf("/") ? rest.slice(0, colon) : rest;
67
+ const parts = path.split("/");
68
+ const name = parts.pop() ?? path;
69
+ const namespace = parts.join("/") || (source === "dockerHub" ? "library" : "");
70
+ if (!name || !namespace) throw new Error(`Cannot read the image ${reference}.`);
15
71
  return {
16
- label: `${name} URL`,
17
- placeholder,
18
- normalize: normalizeUrl
72
+ source,
73
+ namespace,
74
+ name,
75
+ tag,
76
+ ...digest ? { digest } : {}
19
77
  };
20
78
  }
21
- /** Normalizes an instance URL: https by default, no trailing slash or path. */
22
- function normalizeUrl(value) {
23
- const trimmed = value.trim();
24
- const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
25
- let parsed;
26
- try {
27
- parsed = new URL(withScheme);
28
- } catch {
29
- throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
30
- }
31
- return `${parsed.protocol}//${parsed.host}`;
79
+ /** A repository name for Bunny Container Registry. */
80
+ function repositoryName(value) {
81
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "app";
82
+ }
83
+ function escapeRegExp(value) {
84
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
+ }
86
+ /** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
87
+ function hostPattern(name) {
88
+ return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
89
+ }
90
+ /** The names other services reach a service by: its name and its network aliases. */
91
+ function hostNames(service, definition) {
92
+ const names = /* @__PURE__ */ new Set([service]);
93
+ for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
94
+ return [...names];
95
+ }
96
+ /** Replaces service host names with 127.0.0.1, which is the pod on Bunny. */
97
+ function rewriteHosts(text, names) {
98
+ let result = text;
99
+ for (const name of names) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
100
+ return result;
101
+ }
102
+ function containerName(service, taken) {
103
+ let name = service.replace(/-prod$/, "").slice(0, 50) || service;
104
+ for (let index = 2; taken.has(name); index++) name = `${service.slice(0, 46)}-${index}`;
105
+ taken.add(name);
106
+ return name;
107
+ }
108
+ function healthUrl(test) {
109
+ const match = (test ?? []).join(" ").match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::(\d+))?(\/[^\s'"]*)?/);
110
+ if (!match) return void 0;
111
+ return {
112
+ port: Number(match[1] ?? 80),
113
+ path: match[2] ?? "/"
114
+ };
32
115
  }
33
- // src/deploy/coolify.ts
34
- /**
35
- * Coolify (https://coolify.io), through its API at /api/v1 with a bearer
36
- * token. The project deploys as a Docker Compose application that Coolify
37
- * builds on the server from the git repository; Coolify's proxy gets the
38
- * certificate and forwards to the `proxy` service (Caddy) on port 80.
39
- */
40
- /** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
41
- const PLATFORM_ENV = {
42
- CADDY_ADDRESS: ":80",
43
- TRUSTED_PROXIES: "private_ranges",
44
- NUM_PROXIES: "2",
45
- HTTP_PORT: "127.0.0.1:8080",
46
- HTTPS_PORT: "127.0.0.1:8443"
47
- };
48
- /** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
49
- const SHARED_SETUP_FLAGS = [
50
- {
51
- name: "server",
52
- description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
53
- },
54
- {
55
- name: "project",
56
- description: "The platform's project, by name or id (default: the project name)"
57
- },
58
- {
59
- name: "environment",
60
- description: "The platform's environment (default: production)"
61
- }
62
- ];
63
- var CoolifyError = class extends Error {
64
- status;
65
- constructor(message, status) {
66
- super(message);
67
- this.status = status;
116
+ function probeFor(definition, ports) {
117
+ const check = definition.healthcheck;
118
+ if (check?.disable) return void 0;
119
+ const url = check?.test ? healthUrl(check.test) : void 0;
120
+ const port = url?.port ?? ports[0];
121
+ if (port === void 0) return void 0;
122
+ const period = Math.max(1, Math.round(durationSeconds(check?.interval, 10)));
123
+ const timeout = Math.max(1, Math.round(durationSeconds(check?.timeout, 5)));
124
+ const retries = Math.max(1, check?.retries ?? 3);
125
+ const start = durationSeconds(check?.start_period, 0);
126
+ return {
127
+ ...url ? { http: {
128
+ path: url.path,
129
+ port
130
+ } } : { tcp: { port } },
131
+ periodSeconds: period,
132
+ timeoutSeconds: Math.min(timeout, period),
133
+ failureThreshold: Math.min(3, retries),
134
+ startupThreshold: Math.min(1e3, Math.ceil(start / period) + retries)
135
+ };
136
+ }
137
+ /** Plans the Bunny app for a resolved Compose project. */
138
+ function planApp(project, options) {
139
+ const entries = Object.entries(project.services).sort(([a], [b]) => a.localeCompare(b));
140
+ if (!entries.length) throw new Error("compose.yaml has no services for production.");
141
+ const names = /* @__PURE__ */ new Map();
142
+ for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
143
+ const allNames = [...names.values()].flat();
144
+ const builds = /* @__PURE__ */ new Map();
145
+ const baked = /* @__PURE__ */ new Map();
146
+ const ignored = /* @__PURE__ */ new Set();
147
+ const volumes = /* @__PURE__ */ new Set();
148
+ const texts = [];
149
+ for (const [, definition] of entries) for (const value of Object.values(definition.environment ?? {})) if (value) texts.push(value);
150
+ for (const [service, definition] of entries) {
151
+ const binds = (definition.volumes ?? []).filter((mount) => mount.type === "bind");
152
+ if (!binds.length) continue;
153
+ if (definition.build || !definition.image) throw new Error(`${service} bind-mounts ${binds[0]?.source}; Bunny has no bind mounts. Copy the file into the image in its Dockerfile.`);
154
+ const files = binds.map((mount) => {
155
+ let content;
156
+ try {
157
+ content = options.readFile(mount.source ?? "");
158
+ } catch {
159
+ throw new Error(`${service} bind-mounts ${mount.source}, which is not a file; Bunny has no bind mounts, so only files can be copied into the image.`);
160
+ }
161
+ texts.push(content);
162
+ return {
163
+ source: mount.source ?? "",
164
+ target: mount.target,
165
+ content
166
+ };
167
+ });
168
+ const repository = repositoryName(`${project.name}-${service.replace(/-prod$/, "")}`);
169
+ builds.set(repository, {
170
+ kind: "bake",
171
+ repository,
172
+ from: definition.image,
173
+ files
174
+ });
175
+ baked.set(service, repository);
68
176
  }
69
- };
70
- var CoolifyApi = class {
71
- url;
72
- token;
73
- fetchImpl;
74
- constructor(url, token, fetchImpl = fetch) {
75
- this.url = url;
76
- this.token = token;
77
- this.fetchImpl = fetchImpl;
177
+ const portsOf = /* @__PURE__ */ new Map();
178
+ for (const [service, definition] of entries) {
179
+ const found = /* @__PURE__ */ new Set();
180
+ for (const name of names.get(service) ?? []) for (const text of texts) for (const match of text.matchAll(hostPattern(name))) found.add(Number(match[2]));
181
+ const own = definition.healthcheck?.test ? healthUrl(definition.healthcheck.test) : void 0;
182
+ if (own) found.add(own.port);
183
+ for (const port of definition.ports ?? []) found.add(port.target);
184
+ portsOf.set(service, [...found].sort((a, b) => a - b));
78
185
  }
79
- async request(method, path, body) {
80
- let response;
81
- try {
82
- response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
83
- method,
84
- headers: {
85
- Authorization: `Bearer ${this.token}`,
86
- Accept: "application/json",
87
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
88
- },
89
- body: body === void 0 ? void 0 : JSON.stringify(body),
90
- signal: AbortSignal.timeout(3e4)
186
+ const owner = /* @__PURE__ */ new Map();
187
+ const moved = /* @__PURE__ */ new Map();
188
+ for (const [service, definition] of entries) {
189
+ const ports = portsOf.get(service) ?? [];
190
+ for (const [index, port] of ports.entries()) {
191
+ const other = owner.get(port);
192
+ if (!other) {
193
+ owner.set(port, service);
194
+ continue;
195
+ }
196
+ if (!(ports.length === 1 && PORT_ENV_IMAGES.some((pattern) => pattern.test(definition.image ?? "")))) throw new Error(`${other} and ${service} both listen on port ${port}. Bunny runs all containers of an app in one pod, so give one of them another port.`);
197
+ let to = port + 1;
198
+ while (owner.has(to)) to++;
199
+ owner.set(to, service);
200
+ ports[index] = to;
201
+ moved.set(service, {
202
+ from: port,
203
+ to
91
204
  });
92
- } catch (error) {
93
- throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
94
205
  }
95
- const text = await response.text();
96
- if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
97
- if (!text) return void 0;
98
- try {
99
- return JSON.parse(text);
100
- } catch {
101
- return text;
206
+ }
207
+ const published = entries.filter(([, definition]) => (definition.ports ?? []).length);
208
+ if (published.length !== 1) throw new Error(published.length ? `${published.map(([service]) => service).join(" and ")} publish ports; Bunny gives an app one public endpoint, so only the proxy may.` : "No service publishes a port, so there is nothing for Bunny's CDN to send requests to.");
209
+ const [entryService, entryDefinition] = published[0];
210
+ const entryPorts = (entryDefinition.ports ?? []).map((port) => port.target);
211
+ const endpointPort = entryPorts.includes(80) ? 80 : entryPorts[0];
212
+ const rewrite = (text) => {
213
+ let result = text;
214
+ for (const [service, move] of moved) for (const name of names.get(service) ?? []) result = result.replace(hostPattern(name), (whole, before, port) => Number(port) === move.from ? `${before}${name}:${move.to}` : whole);
215
+ return rewriteHosts(result, allNames);
216
+ };
217
+ for (const build of builds.values()) if (build.kind === "bake") for (const file of build.files) file.content = rewrite(file.content);
218
+ const s3 = entries.some(([, definition]) => Boolean(definition.environment?.S3_BUCKET));
219
+ const taken = /* @__PURE__ */ new Set();
220
+ const containers = [];
221
+ for (const [service, definition] of entries) {
222
+ for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
223
+ let image;
224
+ const bake = baked.get(service);
225
+ if (bake) image = {
226
+ source: "push",
227
+ repository: bake
228
+ };
229
+ else if (definition.build) {
230
+ const repository = repositoryName(definition.image ?? `${project.name}-${service}`);
231
+ const existing = builds.get(repository);
232
+ const build = {
233
+ kind: "build",
234
+ repository,
235
+ context: definition.build.context,
236
+ dockerfile: definition.build.dockerfile,
237
+ target: definition.build.target,
238
+ args: definition.build.args
239
+ };
240
+ if (existing && JSON.stringify(existing) !== JSON.stringify(build)) throw new Error(`Two services build the image ${repository} differently; give them their own image names.`);
241
+ builds.set(repository, build);
242
+ image = {
243
+ source: "push",
244
+ repository
245
+ };
246
+ } else if (definition.image) image = parsePublicImage(definition.image);
247
+ else throw new Error(`${service} has neither an image nor a build.`);
248
+ const env = {};
249
+ for (const [key, value] of Object.entries(definition.environment ?? {})) {
250
+ if (value === null || value === void 0 || key === "COMPOSE_PROFILES" || key === "ENV_FILE") continue;
251
+ env[key] = rewrite(value);
252
+ }
253
+ const move = moved.get(service);
254
+ if (move) env.PORT = String(move.to);
255
+ const volumeMounts = [];
256
+ for (const mount of definition.volumes ?? []) {
257
+ if (mount.type === "tmpfs") continue;
258
+ if (mount.type !== "volume" || !mount.source) {
259
+ if (mount.type === "bind") continue;
260
+ throw new Error(`${service} mounts a ${mount.type} volume, which Bunny does not have.`);
261
+ }
262
+ if (mount.source === "media" && !s3) throw new Error("Uploads must be in S3 on Bunny: its volumes belong to one pod and are not backed up. Set S3_BUCKET and the other S3_ variables in .env.production (see the docs on media).");
263
+ if (DROPPED_VOLUMES.has(mount.source)) continue;
264
+ volumes.add(mount.source);
265
+ volumeMounts.push({
266
+ name: mount.source,
267
+ mountPath: mount.target
268
+ });
269
+ if (mount.target === "/var/lib/postgresql/data" && !env.PGDATA) env.PGDATA = `${mount.target}/pgdata`;
102
270
  }
271
+ const ports = portsOf.get(service) ?? [];
272
+ containers.push({
273
+ service,
274
+ name: containerName(service, taken),
275
+ image,
276
+ env,
277
+ ...definition.entrypoint?.length ? { command: definition.entrypoint } : {},
278
+ ...definition.command?.length ? { args: definition.command } : {},
279
+ ...definition.working_dir ? { workingDir: definition.working_dir } : {},
280
+ ports,
281
+ probe: probeFor(definition, move ? [move.to] : ports.filter((port) => port !== 443)),
282
+ volumeMounts,
283
+ ...service === entryService ? { endpoint: endpointPort } : {}
284
+ });
103
285
  }
104
- get = (path) => this.request("GET", path);
105
- post = (path, body) => this.request("POST", path, body ?? {});
106
- patch = (path, body) => this.request("PATCH", path, body);
107
- };
108
- function errorMessage$1(status, text, url) {
109
- if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
110
- if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
111
- if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
112
- let message = "";
286
+ if (volumes.size > 2) throw new Error(`Bunny allows 2 volumes per app, and this project needs ${volumes.size}: ${[...volumes].join(", ")}. Use an external Postgres or S3 for some of them.`);
287
+ return {
288
+ name: project.name,
289
+ containers,
290
+ volumes: [...volumes],
291
+ builds: [...builds.values()],
292
+ ignored: [...ignored].sort()
293
+ };
294
+ }
295
+ // src/deploy/link.ts
296
+ /** Where a project deploys, relative to the project directory. It holds no secrets and can be committed. */
297
+ const LINK_FILE = ".dstack/deploy.json";
298
+ function readLink(directory) {
299
+ const path = join(directory, LINK_FILE);
300
+ if (!existsSync(path)) return void 0;
301
+ let link;
113
302
  try {
114
- const body = JSON.parse(text);
115
- message = body.message ?? "";
116
- if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
303
+ link = JSON.parse(readFileSync(path, "utf8"));
117
304
  } catch {
118
- message = text.slice(0, 200);
305
+ throw new Error(`${LINK_FILE} is not valid JSON.`);
119
306
  }
120
- return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
121
- }
122
- const enc = encodeURIComponent;
123
- function deploymentStatus(value) {
124
- if (value === "queued") return "queued";
125
- if (value === "in_progress") return "building";
126
- if (value === "finished") return "finished";
127
- if (value.startsWith("cancelled")) return "cancelled";
128
- return "failed";
307
+ for (const key of [
308
+ "provider",
309
+ "url",
310
+ "project",
311
+ "environment",
312
+ "application"
313
+ ]) if (typeof link[key] !== "string" || !link[key]) throw new Error(`${LINK_FILE} has no ${key}.`);
314
+ return {
315
+ ...link,
316
+ directory
317
+ };
129
318
  }
130
- function appState(value) {
131
- const [state = "", health = ""] = (value ?? "").split(/[:(]/);
132
- if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
133
- if (state === "degraded" || state === "restarting") return "degraded";
134
- if (state === "exited" || state === "stopped") return "stopped";
135
- return "unknown";
319
+ function writeLink(directory, link) {
320
+ const path = join(directory, LINK_FILE);
321
+ mkdirSync(dirname(path), { recursive: true });
322
+ const { provider, url, project, environment, application } = link;
323
+ writeFileSync(path, `${JSON.stringify({
324
+ provider,
325
+ url,
326
+ project,
327
+ environment,
328
+ application
329
+ }, null, 2)}\n`);
136
330
  }
137
- /** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
138
- function parseLog(logs) {
139
- if (!logs) return [];
140
- let entries;
141
- try {
142
- entries = JSON.parse(logs);
331
+ /** An https URL for a git remote: git@github.com:owner/repo.git becomes https://github.com/owner/repo. */
332
+ function httpsRepository(remote) {
333
+ const value = remote.trim();
334
+ const scp = value.match(/^[\w.-]+@([\w.-]+):(.+)$/);
335
+ let url;
336
+ if (scp) url = `https://${scp[1]}/${scp[2]}`;
337
+ else try {
338
+ const parsed = new URL(value);
339
+ url = `https://${parsed.host.replace(/:\d+$/, "")}${parsed.pathname}`;
143
340
  } catch {
144
- return logs.split("\n").map((text) => ({
145
- text,
146
- stderr: false
147
- }));
341
+ throw new Error(`Cannot read the git remote ${value}.`);
148
342
  }
149
- if (!Array.isArray(entries)) return [];
150
- return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
151
- text,
152
- stderr: entry.type === "stderr"
153
- })));
343
+ return url.replace(/\.git$/, "").replace(/\/$/, "");
154
344
  }
155
- /** The domains of a Compose application, by service. */
156
- function composeDomains(app) {
157
- let value = app.docker_compose_domains;
158
- if (typeof value === "string") try {
159
- value = JSON.parse(value);
345
+ function git(directory, args) {
346
+ try {
347
+ return execFileSync("git", args, {
348
+ cwd: directory,
349
+ encoding: "utf8",
350
+ stdio: [
351
+ "ignore",
352
+ "pipe",
353
+ "ignore"
354
+ ]
355
+ }).trim();
160
356
  } catch {
161
- value = null;
357
+ return;
162
358
  }
163
- const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
164
- if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
165
- return domains.map((domain) => domain.replace(/:\d+$/, ""));
166
- }
167
- function appUrl(api, link) {
168
- return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
169
359
  }
170
- function toDeployment$1(api, link, item) {
171
- const path = item.deployment_url;
172
- return {
173
- id: item.deployment_uuid,
174
- status: deploymentStatus(item.status),
175
- commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
176
- message: item.commit_message?.split("\n")[0] || void 0,
177
- createdAt: item.created_at,
178
- updatedAt: item.updated_at,
179
- url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
180
- log: parseLog(item.logs)
181
- };
360
+ function gitRemote(directory, remote = "origin") {
361
+ return git(directory, [
362
+ "remote",
363
+ "get-url",
364
+ remote
365
+ ]) || void 0;
182
366
  }
183
- /** Finds a name or uuid in a list. */
184
- function find$1(items, wanted) {
185
- return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
367
+ function gitBranch(directory) {
368
+ const branch = git(directory, [
369
+ "rev-parse",
370
+ "--abbrev-ref",
371
+ "HEAD"
372
+ ]);
373
+ return branch && branch !== "HEAD" ? branch : void 0;
374
+ }
375
+ /**
376
+ * Why a deploy of `branch` would not include the local commit, or undefined
377
+ * when origin has it. The platform builds from the remote, not from here.
378
+ */
379
+ function unpushedReason(directory, branch) {
380
+ const head = git(directory, ["rev-parse", "HEAD"]);
381
+ if (!head) return void 0;
382
+ if (git(directory, [
383
+ "rev-parse",
384
+ "--verify",
385
+ "--quiet",
386
+ `refs/remotes/origin/${branch}`
387
+ ]) === void 0) return `origin has no branch ${branch}. Push it first: git push -u origin ${branch}`;
388
+ if (git(directory, [
389
+ "merge-base",
390
+ "--is-ancestor",
391
+ head,
392
+ `refs/remotes/origin/${branch}`
393
+ ]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
394
+ }
395
+ /**
396
+ * Why an upload of HEAD would not match the working tree, or undefined when
397
+ * it does. Providers that upload the local commit deploy HEAD only, so
398
+ * changes to tracked files must be committed first.
399
+ */
400
+ function uncommittedReason(directory) {
401
+ if (git(directory, ["rev-parse", "HEAD"]) === void 0) return "This project has no commit yet. Commit it first: git add -A && git commit";
402
+ if (git(directory, [
403
+ "status",
404
+ "--porcelain",
405
+ "--untracked-files=no"
406
+ ])) return "You have uncommitted changes, so the deploy would not include them. Commit first.";
186
407
  }
187
- var CoolifyClient = class {
188
- api;
189
- constructor(url, token, fetchImpl) {
190
- this.api = new CoolifyApi(url, token, fetchImpl);
191
- }
192
- async verify() {
193
- const version = await this.api.get("/version");
194
- await this.api.get("/servers");
195
- return String(version).trim();
196
- }
197
- async setup(context) {
198
- const { ask, options } = context;
199
- if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
200
- const api = this.api;
201
- const servers = await api.get("/servers");
202
- if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
203
- let server;
204
- if (options.server) {
205
- server = find$1(servers, options.server);
206
- if (!server) throw new Error(`Coolify has no server ${options.server}.`);
207
- } else if (servers.length === 1) server = servers[0];
208
- else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
209
- value: item.uuid,
210
- label: item.name,
211
- hint: item.ip
212
- }))));
213
- if (!server) throw new Error("No server chosen.");
214
- const projects = await api.get("/projects");
215
- let project;
216
- const projectName = options.project ?? context.name;
217
- project = find$1(projects, projectName);
218
- if (!options.project && ask.interactive && projects.length) {
219
- const NEW = "";
220
- const uuid = await ask.select("Coolify project", [...project ? [] : [{
221
- value: NEW,
222
- label: `Create ${projectName}`
223
- }], ...projects.map((item) => ({
224
- value: item.uuid,
225
- label: item.name,
226
- hint: item.description ?? void 0
227
- }))], project?.uuid ?? NEW);
228
- project = uuid === NEW ? void 0 : find$1(projects, uuid);
229
- }
230
- if (!project) {
231
- if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
232
- project = {
233
- uuid: (await api.post("/projects", {
234
- name: projectName,
235
- description: "DjangoStack"
236
- })).uuid,
237
- name: projectName
238
- };
239
- ask.step(`Created the Coolify project ${projectName}`);
240
- }
241
- const environmentName = options.environment ?? "production";
242
- let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
243
- if (!environment) {
244
- environment = {
245
- uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
246
- name: environmentName
247
- };
248
- ask.step(`Created the environment ${environmentName}`);
249
- }
250
- let githubApp;
251
- if (options.githubApp && options.githubApp !== "public") {
252
- const app = find$1(await api.get("/github-apps"), options.githubApp);
253
- if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
254
- githubApp = app.uuid;
255
- } else if (!options.githubApp && ask.interactive) {
256
- const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
257
- if (apps.length) {
258
- const choice = await ask.select("How Coolify gets the code", [{
259
- value: "public",
260
- label: "Public repository",
261
- hint: "no access needed"
262
- }, ...apps.map((app) => ({
263
- value: app.uuid,
264
- label: `GitHub App ${app.name}`,
265
- hint: "private repository"
266
- }))], "public");
267
- githubApp = choice === "public" ? void 0 : choice;
268
- }
269
- }
270
- const body = {
271
- project_uuid: project.uuid,
272
- server_uuid: server.uuid,
273
- environment_uuid: environment.uuid,
274
- git_repository: context.repository,
275
- git_branch: context.branch,
276
- build_pack: "dockercompose",
277
- docker_compose_location: "/compose.yaml",
278
- docker_compose_domains: [{
279
- name: "proxy",
280
- domain: `https://${context.domain}:80`
281
- }],
282
- name: context.name,
283
- description: "DjangoStack",
284
- instant_deploy: false,
285
- ...githubApp ? { github_app_uuid: githubApp } : {}
286
- };
287
- const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
288
- ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
289
- const link = {
290
- provider: "coolify",
291
- url: api.url,
292
- project: project.uuid,
293
- environment: environment.uuid,
294
- application: created.uuid
295
- };
296
- await this.pushEnv(link, context.env);
297
- ask.step(`Set ${Object.keys(context.env).length} environment variables`);
298
- return {
299
- project: link.project,
300
- environment: link.environment,
301
- application: link.application
302
- };
303
- }
304
- async pushEnv(link, env) {
305
- const data = Object.entries(env).map(([key, value]) => ({
306
- key,
307
- value,
308
- is_literal: true,
309
- is_preview: false
310
- }));
311
- await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
312
- }
313
- async deploy(link, options) {
314
- const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
315
- const result = await this.api.post(`/deploy?${query}`);
316
- const id = result?.deployments?.[0]?.deployment_uuid;
317
- if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
318
- return id;
319
- }
320
- async deployment(link, id) {
321
- return toDeployment$1(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
322
- }
323
- async deployments(link, limit) {
324
- const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
325
- return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment$1(this.api, link, item));
326
- }
327
- async status(link) {
328
- const app = await this.api.get(`/applications/${enc(link.application)}`);
329
- return {
330
- state: appState(app.status),
331
- detail: app.status ?? "unknown",
332
- domains: composeDomains(app),
333
- branch: app.git_branch,
334
- url: appUrl(this.api, link)
335
- };
336
- }
337
- };
338
- const coolify = {
339
- id: "coolify",
340
- name: "Coolify",
341
- auth: {
342
- kind: "token",
343
- env: "DSTACK_COOLIFY_TOKEN",
344
- help: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`
345
- },
346
- source: "remote",
347
- target: urlTarget("Coolify", "https://coolify.example.com"),
348
- platformEnv: PLATFORM_ENV,
349
- setupFlags: [...SHARED_SETUP_FLAGS, {
350
- name: "githubApp",
351
- description: "A Coolify GitHub App for a private repository, or public"
352
- }],
353
- connect: (url, token) => {
354
- if (!token) throw new Error("Coolify needs an API key.");
355
- return new CoolifyClient(url, token);
356
- }
357
- };
358
408
  const systemSsh = (args, input) => new Promise((resolve, reject) => {
359
409
  const child = spawn("ssh", args, { stdio: [
360
410
  "pipe",
@@ -653,127 +703,1393 @@ echo $! > ${q(`${record.id}.pid`)}
653
703
  echo $! > ${q(`${link.project}/deploy.lock/pid`)}
654
704
  `, script);
655
705
  }
656
- /** The newest deploy records first, with their status. */
657
- async records(link, limit) {
658
- const output = await this.sh(`
659
- cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
660
- for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
661
- id="\${file%.json}"
662
- cat "$file"
663
- pid=$(cat "$id.pid" 2>/dev/null)
664
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
665
- printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
666
- echo ${MARK}
667
- done
668
- `);
669
- const items = [];
670
- for (const part of parts(`\n${output}`)) {
671
- const lines = part.trim().split("\n");
672
- if (lines.length < 2) continue;
673
- try {
674
- const record = JSON.parse(lines[0] ?? "");
675
- const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
676
- items.push({
677
- record,
678
- status: recordStatus(status, alive === "1"),
679
- updatedAt: updatedAt || void 0
680
- });
681
- } catch {}
682
- }
683
- return items;
706
+ /** The newest deploy records first, with their status. */
707
+ async records(link, limit) {
708
+ const output = await this.sh(`
709
+ cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
710
+ for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
711
+ id="\${file%.json}"
712
+ cat "$file"
713
+ pid=$(cat "$id.pid" 2>/dev/null)
714
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
715
+ printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
716
+ echo ${MARK}
717
+ done
718
+ `);
719
+ const items = [];
720
+ for (const part of parts(`\n${output}`)) {
721
+ const lines = part.trim().split("\n");
722
+ if (lines.length < 2) continue;
723
+ try {
724
+ const record = JSON.parse(lines[0] ?? "");
725
+ const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
726
+ items.push({
727
+ record,
728
+ status: recordStatus(status, alive === "1"),
729
+ updatedAt: updatedAt || void 0
730
+ });
731
+ } catch {}
732
+ }
733
+ return items;
734
+ }
735
+ async deployment(link, id) {
736
+ if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
737
+ const deploys = shellQuote(`${link.project}/deploys`);
738
+ const output = await this.sh(`
739
+ cd ${deploys} || exit 1
740
+ [ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
741
+ cat ${shellQuote(`${id}.json`)}
742
+ pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
743
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
744
+ printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
745
+ echo ${MARK}
746
+ cat ${shellQuote(`${id}.log`)} 2>/dev/null
747
+ `);
748
+ if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
749
+ const [head = "", log = ""] = output.split(`\n${MARK}\n`);
750
+ const [json = "{}", state = ""] = head.split("\n");
751
+ const record = JSON.parse(json);
752
+ const [status = "", alive = "0", updatedAt] = state.split(" ");
753
+ return {
754
+ ...toDeployment$2(record, recordStatus(status, alive === "1"), updatedAt || void 0),
755
+ log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
756
+ text,
757
+ stderr: false
758
+ })) : []
759
+ };
760
+ }
761
+ async deployments(link, limit) {
762
+ return (await this.records(link, limit)).map((item) => ({
763
+ ...toDeployment$2(item.record, item.status, item.updatedAt),
764
+ log: []
765
+ }));
766
+ }
767
+ async status(link) {
768
+ const [ps = "", site = ""] = parts(await this.sh(`
769
+ docker compose -p ${shellQuote(link.application)} ps --all --format json
770
+ echo ${MARK}
771
+ sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
772
+ `));
773
+ const { state, detail } = composeState(parseComposePs(ps));
774
+ const domain = site.trim().replace(/^["']|["']$/g, "");
775
+ return {
776
+ state,
777
+ detail,
778
+ domains: domain ? [domain] : []
779
+ };
780
+ }
781
+ };
782
+ function toDeployment$2(record, status, updatedAt) {
783
+ return {
784
+ id: record.id,
785
+ status,
786
+ commit: record.commit,
787
+ message: record.message,
788
+ createdAt: record.createdAt,
789
+ updatedAt
790
+ };
791
+ }
792
+ /**
793
+ * A value for a Compose .env line: bare when safe, else single-quoted (taken
794
+ * literally), or double-quoted with escapes when it holds a single quote.
795
+ */
796
+ function envValue(value) {
797
+ if (/^[\w@%+=:,./-]*$/.test(value)) return value;
798
+ if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
799
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
800
+ }
801
+ function defaultGit(args) {
802
+ return execFileSync("git", args, {
803
+ maxBuffer: 1073741824,
804
+ stdio: [
805
+ "ignore",
806
+ "pipe",
807
+ "pipe"
808
+ ]
809
+ });
810
+ }
811
+ const ssh = {
812
+ id: "ssh",
813
+ name: "SSH server",
814
+ auth: { kind: "ssh" },
815
+ source: "local",
816
+ target: {
817
+ label: "Server",
818
+ placeholder: "deploy@example.com",
819
+ normalize: (value) => formatTarget(parseTarget(value))
820
+ },
821
+ platformEnv: {},
822
+ setupFlags: [{
823
+ name: "path",
824
+ description: "The project directory on the server (default: /srv/<project>)"
825
+ }],
826
+ connect: (target) => new SshClient(target)
827
+ };
828
+ // src/deploy/bunny.ts
829
+ /**
830
+ * Bunny Magic Containers (https://bunny.net/magic-containers), through the
831
+ * API at https://api.bunny.net/mc with the account API key in the AccessKey
832
+ * header. Bunny has no Compose: the CLI reads the resolved compose.yaml,
833
+ * builds the images of the local commit for linux/amd64, pushes them to
834
+ * Bunny Container Registry and creates one app whose containers are the
835
+ * production services, in one pod on localhost. A CDN endpoint on the
836
+ * proxy carries the site's domain; Bunny's CDN holds the certificate, so
837
+ * Caddy serves plain HTTP on port 80. A deploy pushes new images tagged with
838
+ * the commit and changes the containers' tags, which starts Bunny's rolling
839
+ * update.
840
+ *
841
+ * Link ids: project = the app id, environment = the account's registry
842
+ * namespace, application = the CDN pull zone id.
843
+ */
844
+ const BUNNY_API = "https://api.bunny.net";
845
+ const BUNNY_REGISTRY = "registry.bunny.net";
846
+ /** Variables that put Caddy behind Bunny's CDN. TRUSTED_PROXIES is Bunny's edge list, set in setup. */
847
+ const BUNNY_PLATFORM_ENV = {
848
+ CADDY_ADDRESS: ":80",
849
+ NUM_PROXIES: "2"
850
+ };
851
+ /** Where a deploy's record and log are kept, in the project. Bunny keeps no deploy history. */
852
+ const BUNNY_DEPLOYS_DIR = ".dstack/deploys";
853
+ const systemRunner = (command, args, options = {}) => new Promise((resolvePromise, reject) => {
854
+ const child = spawn(command, args, {
855
+ cwd: options.cwd,
856
+ env: options.env ? {
857
+ ...process.env,
858
+ ...options.env
859
+ } : process.env,
860
+ stdio: [
861
+ "pipe",
862
+ "pipe",
863
+ "pipe"
864
+ ]
865
+ });
866
+ const stdout = [];
867
+ const stderr = [];
868
+ child.stdout.on("data", (chunk) => {
869
+ stdout.push(chunk);
870
+ if (options.stream) process.stderr.write(chunk);
871
+ });
872
+ child.stderr.on("data", (chunk) => {
873
+ stderr.push(chunk);
874
+ if (options.stream) process.stderr.write(chunk);
875
+ });
876
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? `There is no ${command} command here.${command === "docker" ? " Install Docker: https://docs.docker.com/get-docker/" : ""}` : `Could not run ${command}: ${error.message}`)));
877
+ child.on("close", (code) => resolvePromise({
878
+ code: code ?? 1,
879
+ stdout: Buffer.concat(stdout).toString("utf8"),
880
+ stderr: Buffer.concat(stderr).toString("utf8")
881
+ }));
882
+ child.stdin.on("error", () => {});
883
+ child.stdin.end(options.input);
884
+ });
885
+ var BunnyError = class extends Error {
886
+ status;
887
+ constructor(message, status) {
888
+ super(message);
889
+ this.status = status;
890
+ }
891
+ };
892
+ /** The items of a list response: an array, or an object with items, data or a similar key. */
893
+ function listOf(value) {
894
+ if (Array.isArray(value)) return value;
895
+ if (value && typeof value === "object") for (const key of [
896
+ "items",
897
+ "Items",
898
+ "data",
899
+ "applications",
900
+ "registries",
901
+ "containerRegistries",
902
+ "endpoints",
903
+ "regions"
904
+ ]) {
905
+ const items = value[key];
906
+ if (Array.isArray(items)) return items;
907
+ }
908
+ return [];
909
+ }
910
+ function text(value) {
911
+ return typeof value === "string" && value ? value : typeof value === "number" ? String(value) : void 0;
912
+ }
913
+ /** The first value under a key that matches, searched depth first. */
914
+ function findKey(value, pattern) {
915
+ if (!value || typeof value !== "object") return void 0;
916
+ for (const [key, item] of Object.entries(value)) if (pattern.test(key) && item != null && item !== "") return item;
917
+ for (const item of Object.values(value)) {
918
+ const found = findKey(item, pattern);
919
+ if (found !== void 0) return found;
920
+ }
921
+ }
922
+ var BunnyApi = class {
923
+ token;
924
+ fetchImpl;
925
+ base;
926
+ constructor(token, fetchImpl = fetch, base = BUNNY_API) {
927
+ this.token = token;
928
+ this.fetchImpl = fetchImpl;
929
+ this.base = base;
930
+ }
931
+ async request(method, path, body) {
932
+ let response;
933
+ try {
934
+ response = await this.fetchImpl(`${this.base}${path}`, {
935
+ method,
936
+ headers: {
937
+ AccessKey: this.token,
938
+ Accept: "application/json",
939
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
940
+ },
941
+ body: body === void 0 ? void 0 : JSON.stringify(body)
942
+ });
943
+ } catch (error) {
944
+ throw new Error(`Could not reach ${this.base}: ${error.message}`);
945
+ }
946
+ const raw = await response.text();
947
+ let data;
948
+ try {
949
+ data = raw ? JSON.parse(raw) : void 0;
950
+ } catch {
951
+ data = raw;
952
+ }
953
+ if (response.ok) return data;
954
+ if (response.status === 401 || response.status === 403 && !raw) throw new BunnyError("Bunny refused the API key. Use the key of your account (not a sub-user's) from https://dash.bunny.net/account/api-key.", response.status);
955
+ throw new BunnyError(`Bunny answered ${response.status} to ${method} ${path}: ${errorText(data)}`, response.status);
956
+ }
957
+ /** The Magic Containers API. */
958
+ mc(method, path, body) {
959
+ return this.request(method, `/mc${path}`, body);
960
+ }
961
+ };
962
+ function errorText(data) {
963
+ if (typeof data === "string") return data.slice(0, 300) || "no details";
964
+ if (data && typeof data === "object") {
965
+ const value = data;
966
+ const parts = [
967
+ value.title,
968
+ value.detail,
969
+ value.message,
970
+ value.Message,
971
+ value.error
972
+ ].map((item) => text(item)).filter(Boolean);
973
+ const errors = value.errors;
974
+ if (errors && typeof errors === "object") for (const [key, messages] of Object.entries(errors)) parts.push(`${key}: ${Array.isArray(messages) ? messages.join(" ") : String(messages)}`);
975
+ if (parts.length) return parts.join("; ");
976
+ }
977
+ return "no details";
978
+ }
979
+ /** A Bunny probe from a planned one. */
980
+ function probeRequest(probe, failureThreshold) {
981
+ const check = probe.http ? { httpGet: {
982
+ request: {
983
+ path: probe.http.path,
984
+ portNumber: probe.http.port
985
+ },
986
+ response: { expectedStatusCode: "ok" }
987
+ } } : { tcpSocket: { request: { portNumber: probe.tcp?.port } } };
988
+ return {
989
+ initialDelaySeconds: 1,
990
+ periodSeconds: probe.periodSeconds,
991
+ timeoutSeconds: probe.timeoutSeconds,
992
+ failureThreshold,
993
+ successThreshold: 1,
994
+ ...check
995
+ };
996
+ }
997
+ /** The image fields of a container. */
998
+ function imageFields(container, options) {
999
+ const { image } = container;
1000
+ if (image.source === "push") return {
1001
+ imageRegistryId: options.registries.bunny,
1002
+ imageNamespace: options.namespace,
1003
+ imageName: image.repository,
1004
+ imageTag: options.tag
1005
+ };
1006
+ const registry = image.source === "gitHub" ? options.registries.gitHub : options.registries.dockerHub;
1007
+ if (!registry) throw new Error(`There is no public ${image.source === "gitHub" ? "GitHub" : "Docker Hub"} registry in Bunny.`);
1008
+ return {
1009
+ imageRegistryId: registry,
1010
+ imageNamespace: image.namespace,
1011
+ imageName: image.name,
1012
+ imageTag: image.tag,
1013
+ ...image.digest ? { imageDigest: image.digest } : {}
1014
+ };
1015
+ }
1016
+ /** The body of POST /apps for a planned app. */
1017
+ function appRequest(plan, options) {
1018
+ return {
1019
+ name: plan.name.replace(/[^A-Za-z0-9-\s]+/g, "-").slice(0, 100),
1020
+ runtimeType: "shared",
1021
+ autoScaling: {
1022
+ min: 1,
1023
+ max: 1
1024
+ },
1025
+ regionSettings: {
1026
+ allowedRegionIds: [options.region],
1027
+ requiredRegionIds: [options.region],
1028
+ maxAllowedRegions: 1
1029
+ },
1030
+ containerTemplates: plan.containers.map((container) => ({
1031
+ name: container.name,
1032
+ ...imageFields(container, options),
1033
+ imagePullPolicy: "ifNotPresent",
1034
+ ...container.command || container.args || container.workingDir ? { entryPoint: {
1035
+ ...container.command ? { commandArray: container.command } : {},
1036
+ ...container.args ? { argumentsArray: container.args } : {},
1037
+ ...container.workingDir ? { workingDirectory: container.workingDir } : {}
1038
+ } } : {},
1039
+ environmentVariables: envList(container.env),
1040
+ ...container.probe ? { probes: {
1041
+ startup: probeRequest(container.probe, container.probe.startupThreshold),
1042
+ readiness: probeRequest(container.probe, container.probe.failureThreshold)
1043
+ } } : {},
1044
+ ...container.endpoint ? { endpoints: [{
1045
+ displayName: "site",
1046
+ cdn: {
1047
+ isSslEnabled: false,
1048
+ portMappings: [{ containerPort: container.endpoint }]
1049
+ }
1050
+ }] } : {},
1051
+ ...container.volumeMounts.length ? { volumeMounts: container.volumeMounts } : {}
1052
+ })),
1053
+ ...plan.volumes.length ? { volumes: plan.volumes.map((name) => ({
1054
+ name,
1055
+ size: options.volumeSize
1056
+ })) } : {}
1057
+ };
1058
+ }
1059
+ function envList(env) {
1060
+ return Object.entries(env).map(([name, value]) => ({
1061
+ name,
1062
+ value
1063
+ }));
1064
+ }
1065
+ /** Bunny's word for an app's state, read from the fields its API returns. */
1066
+ function bunnyState(value) {
1067
+ const word = (value ?? "").toLowerCase();
1068
+ if (!word) return "unknown";
1069
+ if (/fail|error|crash|unhealthy|degrad/.test(word)) return "degraded";
1070
+ if (/stop|suspend|undeploy|inactive|paused|deleted/.test(word)) return "stopped";
1071
+ if (/run|active|healthy|^ok$|ready|deployed|online/.test(word)) return "running";
1072
+ return "unknown";
1073
+ }
1074
+ /** Whether Bunny is still rolling out. */
1075
+ function bunnyBusy(value) {
1076
+ return /deploying|progress|updat|pending|creat|start|provision|rolling|scaling/i.test(value ?? "");
1077
+ }
1078
+ function appContainers(app) {
1079
+ const value = app;
1080
+ return listOf(value?.containerTemplates ?? value?.containers ?? []).map((item) => ({
1081
+ id: text(item.id) ?? "",
1082
+ name: text(item.name) ?? "",
1083
+ imageNamespace: text(item.imageNamespace),
1084
+ imageName: text(item.imageName),
1085
+ imageTag: text(item.imageTag)
1086
+ })).filter((item) => item.id && item.name);
1087
+ }
1088
+ function appStatusText(app) {
1089
+ const value = app;
1090
+ for (const key of [
1091
+ "status",
1092
+ "state",
1093
+ "appStatus",
1094
+ "displayStatus",
1095
+ "deploymentStatus"
1096
+ ]) {
1097
+ const item = value?.[key];
1098
+ if (typeof item === "string") return item;
1099
+ if (item && typeof item === "object") {
1100
+ const nested = text(item.status ?? item.state ?? item.name);
1101
+ if (nested) return nested;
1102
+ }
1103
+ }
1104
+ }
1105
+ var BunnyClient = class {
1106
+ fetchImpl;
1107
+ api;
1108
+ token;
1109
+ run;
1110
+ now;
1111
+ readFile;
1112
+ edgeServersImpl;
1113
+ constructor(token, fetchImpl = fetch, options = {}) {
1114
+ this.fetchImpl = fetchImpl;
1115
+ this.api = new BunnyApi(token, fetchImpl);
1116
+ this.token = token;
1117
+ this.run = options.run ?? systemRunner;
1118
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1119
+ this.readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
1120
+ this.edgeServersImpl = options.edgeServers ?? (() => this.defaultEdgeServers());
1121
+ }
1122
+ async verify() {
1123
+ const apps = listOf(await this.api.mc("GET", "/apps"));
1124
+ return `Magic Containers (${apps.length} app${apps.length === 1 ? "" : "s"})`;
1125
+ }
1126
+ async docker(args, options = {}) {
1127
+ const result = await this.run("docker", args, options);
1128
+ if (result.code === 0) return result.stdout;
1129
+ const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
1130
+ throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
1131
+ }
1132
+ async git(directory, args) {
1133
+ return this.run("git", [
1134
+ "-C",
1135
+ directory,
1136
+ ...args
1137
+ ]);
1138
+ }
1139
+ /** The short commit and its subject. */
1140
+ async commit(directory) {
1141
+ const result = await this.git(directory, [
1142
+ "log",
1143
+ "-1",
1144
+ "--format=%h%n%s"
1145
+ ]);
1146
+ if (result.code !== 0) throw new Error("This project has no commit yet. Commit it first: git add -A && git commit");
1147
+ const [commit = "", message = ""] = result.stdout.trim().split("\n");
1148
+ return {
1149
+ commit,
1150
+ message
1151
+ };
1152
+ }
1153
+ /** Extracts the local commit into a temporary directory, so the images match the commit. */
1154
+ async checkout(directory) {
1155
+ const target = mkdtempSync(join(tmpdir(), "dstack-bunny-"));
1156
+ const archive = await this.git(directory, [
1157
+ "archive",
1158
+ "--format=tar",
1159
+ "-o",
1160
+ join(target, ".archive.tar"),
1161
+ "HEAD"
1162
+ ]);
1163
+ if (archive.code !== 0) {
1164
+ rmSync(target, {
1165
+ recursive: true,
1166
+ force: true
1167
+ });
1168
+ throw new Error(`git archive failed: ${archive.stderr.trim().split("\n").at(-1) ?? ""}`);
1169
+ }
1170
+ const extract = await this.run("tar", [
1171
+ "-x",
1172
+ "-f",
1173
+ ".archive.tar"
1174
+ ], { cwd: target });
1175
+ rmSync(join(target, ".archive.tar"), { force: true });
1176
+ if (extract.code !== 0) {
1177
+ rmSync(target, {
1178
+ recursive: true,
1179
+ force: true
1180
+ });
1181
+ throw new Error(`Could not extract the commit: ${extract.stderr.trim()}`);
1182
+ }
1183
+ return target;
1184
+ }
1185
+ /** The production Compose project, resolved with the variables, as JSON. */
1186
+ async composeConfig(directory, env) {
1187
+ const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
1188
+ const file = join(folder, ".env");
1189
+ writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
1190
+ try {
1191
+ const output = await this.docker([
1192
+ "compose",
1193
+ "--env-file",
1194
+ file,
1195
+ "config",
1196
+ "--format",
1197
+ "json"
1198
+ ], {
1199
+ cwd: directory,
1200
+ env: {
1201
+ ...env,
1202
+ ENV_FILE: file
1203
+ }
1204
+ });
1205
+ return JSON.parse(output);
1206
+ } finally {
1207
+ rmSync(folder, {
1208
+ recursive: true,
1209
+ force: true
1210
+ });
1211
+ }
1212
+ }
1213
+ async plan(directory, env) {
1214
+ return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
1215
+ }
1216
+ /** Builds an image for linux/amd64 and pushes it to Bunny Container Registry. */
1217
+ async buildAndPush(build, reference, directory) {
1218
+ if (build.kind === "build") await this.docker([
1219
+ "build",
1220
+ "--platform",
1221
+ "linux/amd64",
1222
+ ...build.target ? ["--target", build.target] : [],
1223
+ ...build.dockerfile ? ["-f", resolve(build.context, build.dockerfile)] : [],
1224
+ ...Object.entries(build.args ?? {}).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
1225
+ "-t",
1226
+ reference,
1227
+ build.context
1228
+ ], {
1229
+ cwd: directory,
1230
+ stream: true
1231
+ });
1232
+ else {
1233
+ const context = mkdtempSync(join(tmpdir(), "dstack-bake-"));
1234
+ try {
1235
+ const lines = [`FROM ${build.from}`];
1236
+ build.files.forEach((file, index) => {
1237
+ writeFileSync(join(context, `file-${index}`), file.content);
1238
+ lines.push(`COPY file-${index} ${file.target}`);
1239
+ });
1240
+ writeFileSync(join(context, "Dockerfile"), `${lines.join("\n")}\n`);
1241
+ await this.docker([
1242
+ "build",
1243
+ "--platform",
1244
+ "linux/amd64",
1245
+ "-t",
1246
+ reference,
1247
+ context
1248
+ ], { stream: true });
1249
+ } finally {
1250
+ rmSync(context, {
1251
+ recursive: true,
1252
+ force: true
1253
+ });
1254
+ }
1255
+ }
1256
+ const config = mkdtempSync(join(tmpdir(), "dstack-docker-"));
1257
+ try {
1258
+ await this.docker([
1259
+ "login",
1260
+ BUNNY_REGISTRY,
1261
+ "--username",
1262
+ "token",
1263
+ "--password-stdin"
1264
+ ], {
1265
+ env: { DOCKER_CONFIG: config },
1266
+ input: this.token
1267
+ });
1268
+ await this.docker(["push", reference], {
1269
+ env: { DOCKER_CONFIG: config },
1270
+ stream: true
1271
+ });
1272
+ } finally {
1273
+ rmSync(config, {
1274
+ recursive: true,
1275
+ force: true
1276
+ });
1277
+ }
1278
+ }
1279
+ /** Builds and pushes every image of the local commit; returns the plan of the commit. */
1280
+ async pushImages(directory, env, namespace, tag, log) {
1281
+ const checkout = await this.checkout(directory);
1282
+ try {
1283
+ const plan = await this.plan(checkout, env);
1284
+ for (const build of plan.builds) {
1285
+ const reference = `${BUNNY_REGISTRY}/${namespace}/${build.repository}:${tag}`;
1286
+ log(`Building and pushing ${reference}`);
1287
+ await this.buildAndPush(build, reference, checkout);
1288
+ }
1289
+ return plan;
1290
+ } finally {
1291
+ rmSync(checkout, {
1292
+ recursive: true,
1293
+ force: true
1294
+ });
1295
+ }
1296
+ }
1297
+ async registries() {
1298
+ const list = listOf(await this.api.mc("GET", "/registries"));
1299
+ const registries = {};
1300
+ let namespace;
1301
+ for (const item of list) {
1302
+ const type = (text(item.type) ?? "").toLowerCase();
1303
+ const id = text(item.id);
1304
+ if (!id) continue;
1305
+ if (type === "dockerhub" && !registries.dockerHub) registries.dockerHub = id;
1306
+ else if (type === "github" && !registries.gitHub) registries.gitHub = id;
1307
+ else if ((type === "bunnyregistry" || type === "bunny") && !registries.bunny) {
1308
+ registries.bunny = id;
1309
+ namespace = text(item.namespace ?? item.accountId ?? item.userName ?? item.username);
1310
+ }
1311
+ }
1312
+ return {
1313
+ registries,
1314
+ namespace
1315
+ };
1316
+ }
1317
+ async ensureRegistries() {
1318
+ const found = await this.registries();
1319
+ if (!found.registries.dockerHub) throw new Error("Bunny lists no public Docker Hub registry for this account. Check Magic Containers › Image Registries.");
1320
+ if (!found.registries.bunny) {
1321
+ const created = await this.api.mc("POST", "/registries", {
1322
+ displayName: "Bunny Container Registry",
1323
+ type: "bunnyRegistry"
1324
+ });
1325
+ const id = text(created?.id) ?? text(findKey(created, /^id$/i));
1326
+ if (!id) throw new Error("Could not add Bunny Container Registry to Magic Containers.");
1327
+ found.registries.bunny = id;
1328
+ }
1329
+ return {
1330
+ registries: found.registries,
1331
+ namespace: found.namespace
1332
+ };
1333
+ }
1334
+ async app(link) {
1335
+ return await this.api.mc("GET", `/apps/${encodeURIComponent(link.project)}`) ?? {};
1336
+ }
1337
+ async defaultEdgeServers() {
1338
+ const addresses = [];
1339
+ for (const path of ["/system/edgeserverlist", "/system/edgeserverlist/ipv6"]) {
1340
+ const response = await this.fetchImpl(`${BUNNY_API}${path}`, { headers: { Accept: "application/json" } });
1341
+ if (!response.ok) throw new Error(`Bunny answered ${response.status} to ${path}`);
1342
+ const list = await response.json();
1343
+ if (Array.isArray(list)) {
1344
+ for (const item of list) if (typeof item === "string") addresses.push(item);
1345
+ }
1346
+ }
1347
+ return addresses;
1348
+ }
1349
+ /** TRUSTED_PROXIES for Caddy: the addresses Bunny's CDN connects from. */
1350
+ async trustedProxies() {
1351
+ try {
1352
+ const addresses = await this.edgeServersImpl();
1353
+ return addresses.length ? addresses.join(" ") : void 0;
1354
+ } catch {
1355
+ return;
1356
+ }
1357
+ }
1358
+ async pullZoneId(appId) {
1359
+ for (let attempt = 0; attempt < 10; attempt++) {
1360
+ const id = text(findKey(await this.api.mc("GET", `/apps/${encodeURIComponent(appId)}/endpoints`), /^pull_?zone_?id$/i));
1361
+ if (id && id !== "0") return id;
1362
+ await new Promise((done) => setTimeout(done, attempt ? 3e3 : 1e3));
1363
+ }
1364
+ }
1365
+ async pullZone(id) {
1366
+ return await this.api.request("GET", `/pullzone/${encodeURIComponent(id)}`) ?? {};
1367
+ }
1368
+ /** The custom hostname, its certificate and a cache bypass for pages that must not be cached. */
1369
+ async setupPullZone(id, domain, step) {
1370
+ const zone = `/pullzone/${encodeURIComponent(id)}`;
1371
+ await this.api.request("POST", zone, { AddHostHeader: true });
1372
+ if (!listOf((await this.pullZone(id)).Hostnames).map((item) => text(item.Value)?.toLowerCase()).includes(domain.toLowerCase())) {
1373
+ await this.api.request("POST", `${zone}/addHostname`, { Hostname: domain });
1374
+ step(`Added ${domain} to the CDN pull zone`);
1375
+ }
1376
+ await this.api.request("POST", `${zone}/edgerules/addOrUpdate`, {
1377
+ ActionType: 3,
1378
+ ActionParameter1: "0",
1379
+ ActionParameter2: "0",
1380
+ Triggers: [{
1381
+ Type: 0,
1382
+ PatternMatches: [
1383
+ "*/admin",
1384
+ "*/admin/*",
1385
+ "*/api",
1386
+ "*/api/*",
1387
+ "*/_a/*"
1388
+ ],
1389
+ PatternMatchingType: 0
1390
+ }],
1391
+ TriggerMatchingType: 0,
1392
+ Description: "dstack: do not cache the admin, the API and analytics",
1393
+ Enabled: true
1394
+ });
1395
+ await this.certificate(id, domain);
1396
+ }
1397
+ /** Requests the free certificate once DNS points at the CDN; returns whether it is active. */
1398
+ async certificate(id, domain) {
1399
+ if (listOf((await this.pullZone(id)).Hostnames).find((item) => text(item.Value)?.toLowerCase() === domain.toLowerCase())?.HasCertificate === true) return true;
1400
+ try {
1401
+ await this.api.request("GET", `/pullzone/loadFreeCertificate?hostname=${encodeURIComponent(domain)}`);
1402
+ await this.api.request("POST", `/pullzone/${encodeURIComponent(id)}/setForceSSL`, {
1403
+ Hostname: domain,
1404
+ ForceSSL: true
1405
+ });
1406
+ return true;
1407
+ } catch {
1408
+ return false;
1409
+ }
1410
+ }
1411
+ async cname(id) {
1412
+ const name = text((await this.pullZone(id)).Name);
1413
+ return name ? `${name}.b-cdn.net` : void 0;
1414
+ }
1415
+ recordsDir(directory) {
1416
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1417
+ if (!existsSync(folder)) {
1418
+ mkdirSync(folder, { recursive: true });
1419
+ writeFileSync(join(folder, ".gitignore"), "*\n");
1420
+ }
1421
+ return folder;
1422
+ }
1423
+ writeRecord(directory, record, log) {
1424
+ const folder = this.recordsDir(directory);
1425
+ writeFileSync(join(folder, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`);
1426
+ writeFileSync(join(folder, `${record.id}.log`), log.length ? `${log.join("\n")}\n` : "");
1427
+ }
1428
+ records(directory) {
1429
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1430
+ if (!existsSync(folder)) return [];
1431
+ const records = [];
1432
+ for (const file of readdirSync(folder)) {
1433
+ if (!file.endsWith(".json")) continue;
1434
+ try {
1435
+ records.push(JSON.parse(readFileSync(join(folder, file), "utf8")));
1436
+ } catch {}
1437
+ }
1438
+ return records.sort((a, b) => b.id.localeCompare(a.id));
1439
+ }
1440
+ async setup(context) {
1441
+ const { ask, options } = context;
1442
+ const { directory } = context;
1443
+ const volumeSize = Number.parseInt(options.volumeSize ?? "10", 10);
1444
+ if (!Number.isFinite(volumeSize) || volumeSize < 1 || volumeSize > 100) throw new Error("Give --volume-size in GB, from 1 to 100.");
1445
+ const env = { ...context.env };
1446
+ const trusted = await this.trustedProxies();
1447
+ if (trusted) env.TRUSTED_PROXIES = trusted;
1448
+ else ask.step("Could not read Bunny's edge server list; Django may see the CDN's address instead of the client's. Run dstack deploy env push later.");
1449
+ const local = await this.plan(directory, env);
1450
+ if (local.ignored.length) ask.step(`Bunny runs the app in a gVisor sandbox; not applied from compose.yaml: ${local.ignored.join(", ")}`);
1451
+ const { registries, namespace: found } = await this.ensureRegistries();
1452
+ let namespace = options.account ?? found;
1453
+ if (!namespace) namespace = (await ask.text("Bunny account id (Magic Containers › Image Registries, or bunny whoami)", { placeholder: "123456" })).trim();
1454
+ if (!/^[\w.-]+$/.test(namespace)) throw new Error(`${namespace} is not a Bunny account id.`);
1455
+ let region = options.region;
1456
+ if (!region) {
1457
+ const optimal = text(findKey(await this.api.mc("GET", "/regions/optimal").catch(() => void 0), /^id$|regionid/i));
1458
+ const regions = listOf(await this.api.mc("GET", "/regions")).map((item) => ({
1459
+ value: text(item.id) ?? "",
1460
+ label: text(item.name) ?? text(item.id) ?? ""
1461
+ })).filter((item) => item.value);
1462
+ if (regions.length) region = await ask.select("Region", regions, optimal ?? regions[0]?.value);
1463
+ else region = optimal;
1464
+ }
1465
+ if (!region) throw new Error("Pass --region, such as DE.");
1466
+ const { commit, message } = await this.commit(directory);
1467
+ const uncommitted = uncommittedReason(directory);
1468
+ if (uncommitted) ask.step(`Building the last commit, ${commit}: ${uncommitted.replace(/ Commit first\.$/, "")}`);
1469
+ const log = [];
1470
+ const plan = await this.pushImages(directory, env, namespace, commit, (line) => {
1471
+ log.push(line);
1472
+ ask.step(line);
1473
+ });
1474
+ const appId = text((await this.api.mc("POST", "/apps", appRequest(plan, {
1475
+ registries,
1476
+ namespace,
1477
+ tag: commit,
1478
+ region,
1479
+ volumeSize
1480
+ })))?.id);
1481
+ if (!appId) throw new Error("Bunny created the app but returned no id.");
1482
+ ask.step(`Created the app ${plan.name} in ${region}`);
1483
+ try {
1484
+ await this.api.mc("POST", `/apps/${encodeURIComponent(appId)}/deploy`);
1485
+ } catch (error) {
1486
+ if (!(error instanceof BunnyError && [400, 409].includes(error.status))) throw error;
1487
+ }
1488
+ const zone = await this.pullZoneId(appId);
1489
+ if (!zone) throw new Error(`Bunny has not created the CDN endpoint of app ${appId} yet. Run dstack deploy setup --force later.`);
1490
+ await this.setupPullZone(zone, context.domain, ask.step);
1491
+ const target = await this.cname(zone);
1492
+ ask.step(`Add this DNS record: ${context.domain} CNAME ${target ?? "the pull zone's b-cdn.net address"}${context.domain.split(".").length === 2 ? " (an apex domain needs Bunny DNS, or use www and redirect)" : ""}`);
1493
+ this.writeRecord(directory, {
1494
+ id: deployId(this.now(), commit),
1495
+ tag: commit,
1496
+ commit,
1497
+ message,
1498
+ createdAt: this.now().toISOString()
1499
+ }, log);
1500
+ return {
1501
+ project: appId,
1502
+ environment: namespace,
1503
+ application: zone
1504
+ };
1505
+ }
1506
+ async pushEnv(link, env) {
1507
+ const directory = projectDirectory(link);
1508
+ const values = { ...env };
1509
+ const trusted = await this.trustedProxies();
1510
+ if (trusted) values.TRUSTED_PROXIES = trusted;
1511
+ const plan = await this.plan(directory, values);
1512
+ const containers = appContainers(await this.app(link));
1513
+ for (const container of plan.containers) {
1514
+ const remote = containers.find((item) => item.name === container.name);
1515
+ if (!remote) throw new Error(missingContainer(container.name));
1516
+ await this.api.mc("PUT", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(remote.id)}/env`, { environmentVariables: envList(container.env) });
1517
+ }
1518
+ }
1519
+ async deploy(link, options) {
1520
+ const { directory } = options;
1521
+ const { commit, message } = await this.commit(directory);
1522
+ const id = deployId(this.now(), commit);
1523
+ const log = [];
1524
+ const record = {
1525
+ id,
1526
+ tag: commit,
1527
+ commit,
1528
+ message,
1529
+ createdAt: this.now().toISOString()
1530
+ };
1531
+ const containers = appContainers(await this.app(link));
1532
+ const env = {
1533
+ ...productionEnv(directory),
1534
+ ...BUNNY_PLATFORM_ENV
1535
+ };
1536
+ try {
1537
+ const local = await this.plan(directory, env);
1538
+ const pushed = local.containers.filter((container) => container.image.source === "push");
1539
+ for (const container of local.containers) if (!containers.some((item) => item.name === container.name)) throw new Error(missingContainer(container.name));
1540
+ if (pushed.every((container) => containers.find((item) => item.name === container.name)?.imageTag === commit)) {
1541
+ if (options.force) {
1542
+ await this.api.mc("POST", `/apps/${encodeURIComponent(link.project)}/restart`);
1543
+ log.push(`Restarted the app on ${commit}`);
1544
+ } else log.push(`The app already runs ${commit}; nothing to deploy. Pass --force to restart it.`);
1545
+ } else {
1546
+ await this.pushImages(directory, env, link.environment, commit, (line) => {
1547
+ log.push(line);
1548
+ process.stderr.write(`${line}\n`);
1549
+ });
1550
+ await this.retag(link, containers, pushed.map((container) => container.name), commit);
1551
+ log.push(`Bunny is rolling out ${commit}`);
1552
+ }
1553
+ if (link.application) {
1554
+ const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
1555
+ if (domain) await this.certificate(link.application, domain).catch(() => false);
1556
+ }
1557
+ } catch (error) {
1558
+ log.push(error.message);
1559
+ this.writeRecord(directory, {
1560
+ ...record,
1561
+ failed: true
1562
+ }, log);
1563
+ throw error;
1564
+ }
1565
+ this.writeRecord(directory, record, log);
1566
+ this.prune(directory);
1567
+ return id;
1568
+ }
1569
+ /** Points the containers of pushed images at a tag; Bunny starts a rolling update. */
1570
+ async retag(link, remote, names, tag) {
1571
+ for (const name of names) {
1572
+ const item = remote.find((entry) => entry.name === name);
1573
+ if (!item || item.imageTag === tag) continue;
1574
+ await this.api.mc("PATCH", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(item.id)}`, { imageTag: tag });
1575
+ }
1576
+ }
1577
+ /** Removes records beyond KEEP_DEPLOYS. Their images stay in the registry. */
1578
+ prune(directory) {
1579
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1580
+ for (const record of this.records(directory).slice(20)) {
1581
+ rmSync(join(folder, `${record.id}.json`), { force: true });
1582
+ rmSync(join(folder, `${record.id}.log`), { force: true });
1583
+ }
1584
+ }
1585
+ async rollback(link, to) {
1586
+ const directory = projectDirectory(link);
1587
+ const records = this.records(directory);
1588
+ const containers = appContainers(await this.app(link));
1589
+ const pushed = containers.filter((item) => isPushed(item, link));
1590
+ const currentTag = pushed[0]?.imageTag;
1591
+ let target;
1592
+ if (to) {
1593
+ target = records.find((item) => item.id === to || item.tag === to);
1594
+ if (!target) throw new Error(`There is no deploy ${to} in ${BUNNY_DEPLOYS_DIR}.`);
1595
+ } else {
1596
+ target = records.find((item) => !item.failed && item.tag !== currentTag);
1597
+ if (!target) throw new Error("There is no earlier deploy to roll back to.");
1598
+ }
1599
+ if (target.failed) throw new Error(`Deploy ${target.id} failed, so its images may not exist.`);
1600
+ const id = deployId(this.now(), "rollback");
1601
+ await this.retag(link, containers, pushed.map((item) => item.name), target.tag);
1602
+ this.writeRecord(directory, {
1603
+ id,
1604
+ tag: target.tag,
1605
+ commit: target.commit,
1606
+ message: `Rollback to ${target.id}`,
1607
+ createdAt: this.now().toISOString(),
1608
+ rollbackOf: target.id
1609
+ }, [`Bunny is rolling out ${target.tag}`]);
1610
+ return id;
1611
+ }
1612
+ /** The tag the app's own images run, and Bunny's state. */
1613
+ async current(link) {
1614
+ const app = await this.app(link);
1615
+ return {
1616
+ tag: appContainers(app).filter((item) => isPushed(item, link))[0]?.imageTag,
1617
+ status: appStatusText(app)
1618
+ };
1619
+ }
1620
+ statusOf(record, latest, current) {
1621
+ if (record.failed) return "failed";
1622
+ if (record.tag === current.tag && latest) {
1623
+ if (bunnyBusy(current.status)) return "building";
1624
+ const state = bunnyState(current.status);
1625
+ if (state === "running" || state === "unknown") return "finished";
1626
+ return "failed";
1627
+ }
1628
+ if (latest) return this.now().getTime() - new Date(record.createdAt).getTime() < 12e4 ? "queued" : "failed";
1629
+ return "finished";
1630
+ }
1631
+ async deployment(link, id) {
1632
+ const directory = projectDirectory(link);
1633
+ const records = this.records(directory);
1634
+ const record = records.find((item) => item.id === id);
1635
+ if (!record) throw new Error(`There is no deploy ${id} in ${BUNNY_DEPLOYS_DIR}.`);
1636
+ const current = await this.current(link);
1637
+ const logFile = join(directory, BUNNY_DEPLOYS_DIR, `${id}.log`);
1638
+ const log = existsSync(logFile) ? readFileSync(logFile, "utf8").replace(/\n$/, "") : "";
1639
+ return {
1640
+ ...toDeployment$1(record, this.statusOf(record, records[0]?.id === id, current), link),
1641
+ log: log ? log.split("\n").map((line) => ({
1642
+ text: line,
1643
+ stderr: false
1644
+ })) : []
1645
+ };
1646
+ }
1647
+ async deployments(link, limit) {
1648
+ const records = this.records(projectDirectory(link)).slice(0, Math.max(1, limit));
1649
+ if (!records.length) return [];
1650
+ const current = await this.current(link);
1651
+ return records.map((record, index) => ({
1652
+ ...toDeployment$1(record, this.statusOf(record, index === 0, current), link),
1653
+ log: []
1654
+ }));
1655
+ }
1656
+ async status(link) {
1657
+ const word = appStatusText(await this.app(link));
1658
+ const state = bunnyBusy(word) ? "degraded" : bunnyState(word);
1659
+ const details = [word ?? "no status"];
1660
+ const domains = [];
1661
+ if (link.application) try {
1662
+ const zone = await this.pullZone(link.application);
1663
+ for (const item of listOf(zone.Hostnames)) {
1664
+ const host = text(item.Value);
1665
+ if (!host || item.IsSystemHostname === true || host.endsWith(".b-cdn.net")) continue;
1666
+ domains.push(`https://${host}`);
1667
+ if (item.HasCertificate !== true) details.push(`no certificate for ${host} yet: check its DNS record`);
1668
+ }
1669
+ } catch {
1670
+ details.push("could not read the CDN pull zone");
1671
+ }
1672
+ return {
1673
+ state,
1674
+ detail: details.join(", "),
1675
+ domains,
1676
+ url: dashboardUrl(link)
1677
+ };
1678
+ }
1679
+ };
1680
+ function missingContainer(name) {
1681
+ return `The app on Bunny has no container ${name}. compose.yaml changed since setup: run dstack deploy setup --force to create the app again.`;
1682
+ }
1683
+ /** Whether a Bunny container runs an image this project pushes: one in the account's namespace. */
1684
+ function isPushed(container, link) {
1685
+ return container.imageNamespace === link.environment;
1686
+ }
1687
+ /** The project directory, where the deploy records are. */
1688
+ function projectDirectory(link) {
1689
+ return link.directory ?? process.cwd();
1690
+ }
1691
+ function dashboardUrl(link) {
1692
+ return `https://dash.bunny.net/magic-containers/apps/${encodeURIComponent(link.project)}`;
1693
+ }
1694
+ function toDeployment$1(record, status, link) {
1695
+ return {
1696
+ id: record.id,
1697
+ status,
1698
+ commit: record.commit,
1699
+ message: record.message,
1700
+ createdAt: record.createdAt,
1701
+ url: dashboardUrl(link)
1702
+ };
1703
+ }
1704
+ /** The variables of .env.production, or of .env on a machine where that is the production one. */
1705
+ function productionEnv(directory) {
1706
+ for (const name of [".env.production", ".env"]) {
1707
+ const file = join(directory, name);
1708
+ if (existsSync(file)) return parseEnv(readFileSync(file, "utf8"));
1709
+ }
1710
+ throw new Error(".env.production does not exist. Run dstack production --domain example.com first.");
1711
+ }
1712
+ const bunny = {
1713
+ id: "bunny",
1714
+ name: "Bunny Magic Containers",
1715
+ auth: {
1716
+ kind: "token",
1717
+ env: "DSTACK_BUNNY_TOKEN",
1718
+ help: () => "Copy the API key of your bunny.net account from https://dash.bunny.net/account/api-key. It also logs in to Bunny Container Registry."
1719
+ },
1720
+ source: "local",
1721
+ target: {
1722
+ label: "Bunny API",
1723
+ placeholder: BUNNY_API,
1724
+ fixed: BUNNY_API,
1725
+ normalize: () => BUNNY_API
1726
+ },
1727
+ platformEnv: BUNNY_PLATFORM_ENV,
1728
+ setupFlags: [
1729
+ {
1730
+ name: "region",
1731
+ description: "Bunny: the region of the app, such as DE (default: the closest)"
1732
+ },
1733
+ {
1734
+ name: "volumeSize",
1735
+ description: "Bunny: the size of the database volume in GB (default: 10)"
1736
+ },
1737
+ {
1738
+ name: "account",
1739
+ description: "Bunny: the account id, the namespace in Bunny Container Registry"
1740
+ }
1741
+ ],
1742
+ printsDns: true,
1743
+ connect: (_target, token) => {
1744
+ if (!token) throw new Error("Bunny needs an API key.");
1745
+ return new BunnyClient(token);
1746
+ }
1747
+ };
1748
+ // src/deploy/provider.ts
1749
+ /** The target of a platform with a web API and an API key. */
1750
+ function urlTarget(name, placeholder) {
1751
+ return {
1752
+ label: `${name} URL`,
1753
+ placeholder,
1754
+ normalize: normalizeUrl
1755
+ };
1756
+ }
1757
+ /** Normalizes an instance URL: https by default, no trailing slash or path. */
1758
+ function normalizeUrl(value) {
1759
+ const trimmed = value.trim();
1760
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
1761
+ let parsed;
1762
+ try {
1763
+ parsed = new URL(withScheme);
1764
+ } catch {
1765
+ throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
1766
+ }
1767
+ return `${parsed.protocol}//${parsed.host}`;
1768
+ }
1769
+ // src/deploy/coolify.ts
1770
+ /**
1771
+ * Coolify (https://coolify.io), through its API at /api/v1 with a bearer
1772
+ * token. The project deploys as a Docker Compose application that Coolify
1773
+ * builds on the server from the git repository; Coolify's proxy gets the
1774
+ * certificate and forwards to the `proxy` service (Caddy) on port 80.
1775
+ */
1776
+ /** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
1777
+ const PLATFORM_ENV = {
1778
+ CADDY_ADDRESS: ":80",
1779
+ TRUSTED_PROXIES: "private_ranges",
1780
+ NUM_PROXIES: "2",
1781
+ HTTP_PORT: "127.0.0.1:8080",
1782
+ HTTPS_PORT: "127.0.0.1:8443"
1783
+ };
1784
+ /** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
1785
+ const SHARED_SETUP_FLAGS = [
1786
+ {
1787
+ name: "server",
1788
+ description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
1789
+ },
1790
+ {
1791
+ name: "project",
1792
+ description: "The platform's project, by name or id (default: the project name)"
1793
+ },
1794
+ {
1795
+ name: "environment",
1796
+ description: "The platform's environment (default: production)"
1797
+ }
1798
+ ];
1799
+ var CoolifyError = class extends Error {
1800
+ status;
1801
+ constructor(message, status) {
1802
+ super(message);
1803
+ this.status = status;
1804
+ }
1805
+ };
1806
+ var CoolifyApi = class {
1807
+ url;
1808
+ token;
1809
+ fetchImpl;
1810
+ constructor(url, token, fetchImpl = fetch) {
1811
+ this.url = url;
1812
+ this.token = token;
1813
+ this.fetchImpl = fetchImpl;
1814
+ }
1815
+ async request(method, path, body) {
1816
+ let response;
1817
+ try {
1818
+ response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
1819
+ method,
1820
+ headers: {
1821
+ Authorization: `Bearer ${this.token}`,
1822
+ Accept: "application/json",
1823
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
1824
+ },
1825
+ body: body === void 0 ? void 0 : JSON.stringify(body),
1826
+ signal: AbortSignal.timeout(3e4)
1827
+ });
1828
+ } catch (error) {
1829
+ throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
1830
+ }
1831
+ const text = await response.text();
1832
+ if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
1833
+ if (!text) return void 0;
1834
+ try {
1835
+ return JSON.parse(text);
1836
+ } catch {
1837
+ return text;
1838
+ }
1839
+ }
1840
+ get = (path) => this.request("GET", path);
1841
+ post = (path, body) => this.request("POST", path, body ?? {});
1842
+ patch = (path, body) => this.request("PATCH", path, body);
1843
+ };
1844
+ function errorMessage$1(status, text, url) {
1845
+ if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
1846
+ if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
1847
+ if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
1848
+ let message = "";
1849
+ try {
1850
+ const body = JSON.parse(text);
1851
+ message = body.message ?? "";
1852
+ if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
1853
+ } catch {
1854
+ message = text.slice(0, 200);
1855
+ }
1856
+ return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
1857
+ }
1858
+ const enc = encodeURIComponent;
1859
+ function deploymentStatus(value) {
1860
+ if (value === "queued") return "queued";
1861
+ if (value === "in_progress") return "building";
1862
+ if (value === "finished") return "finished";
1863
+ if (value.startsWith("cancelled")) return "cancelled";
1864
+ return "failed";
1865
+ }
1866
+ function appState(value) {
1867
+ const [state = "", health = ""] = (value ?? "").split(/[:(]/);
1868
+ if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
1869
+ if (state === "degraded" || state === "restarting") return "degraded";
1870
+ if (state === "exited" || state === "stopped") return "stopped";
1871
+ return "unknown";
1872
+ }
1873
+ /** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
1874
+ function parseLog(logs) {
1875
+ if (!logs) return [];
1876
+ let entries;
1877
+ try {
1878
+ entries = JSON.parse(logs);
1879
+ } catch {
1880
+ return logs.split("\n").map((text) => ({
1881
+ text,
1882
+ stderr: false
1883
+ }));
1884
+ }
1885
+ if (!Array.isArray(entries)) return [];
1886
+ return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
1887
+ text,
1888
+ stderr: entry.type === "stderr"
1889
+ })));
1890
+ }
1891
+ /** The domains of a Compose application, by service. */
1892
+ function composeDomains(app) {
1893
+ let value = app.docker_compose_domains;
1894
+ if (typeof value === "string") try {
1895
+ value = JSON.parse(value);
1896
+ } catch {
1897
+ value = null;
1898
+ }
1899
+ const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
1900
+ if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
1901
+ return domains.map((domain) => domain.replace(/:\d+$/, ""));
1902
+ }
1903
+ function appUrl(api, link) {
1904
+ return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
1905
+ }
1906
+ function toDeployment(api, link, item) {
1907
+ const path = item.deployment_url;
1908
+ return {
1909
+ id: item.deployment_uuid,
1910
+ status: deploymentStatus(item.status),
1911
+ commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
1912
+ message: item.commit_message?.split("\n")[0] || void 0,
1913
+ createdAt: item.created_at,
1914
+ updatedAt: item.updated_at,
1915
+ url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
1916
+ log: parseLog(item.logs)
1917
+ };
1918
+ }
1919
+ /** Finds a name or uuid in a list. */
1920
+ function find$1(items, wanted) {
1921
+ return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
1922
+ }
1923
+ var CoolifyClient = class {
1924
+ api;
1925
+ constructor(url, token, fetchImpl) {
1926
+ this.api = new CoolifyApi(url, token, fetchImpl);
1927
+ }
1928
+ async verify() {
1929
+ const version = await this.api.get("/version");
1930
+ await this.api.get("/servers");
1931
+ return String(version).trim();
1932
+ }
1933
+ async setup(context) {
1934
+ const { ask, options } = context;
1935
+ if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
1936
+ const api = this.api;
1937
+ const servers = await api.get("/servers");
1938
+ if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
1939
+ let server;
1940
+ if (options.server) {
1941
+ server = find$1(servers, options.server);
1942
+ if (!server) throw new Error(`Coolify has no server ${options.server}.`);
1943
+ } else if (servers.length === 1) server = servers[0];
1944
+ else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
1945
+ value: item.uuid,
1946
+ label: item.name,
1947
+ hint: item.ip
1948
+ }))));
1949
+ if (!server) throw new Error("No server chosen.");
1950
+ const projects = await api.get("/projects");
1951
+ let project;
1952
+ const projectName = options.project ?? context.name;
1953
+ project = find$1(projects, projectName);
1954
+ if (!options.project && ask.interactive && projects.length) {
1955
+ const NEW = "";
1956
+ const uuid = await ask.select("Coolify project", [...project ? [] : [{
1957
+ value: NEW,
1958
+ label: `Create ${projectName}`
1959
+ }], ...projects.map((item) => ({
1960
+ value: item.uuid,
1961
+ label: item.name,
1962
+ hint: item.description ?? void 0
1963
+ }))], project?.uuid ?? NEW);
1964
+ project = uuid === NEW ? void 0 : find$1(projects, uuid);
1965
+ }
1966
+ if (!project) {
1967
+ if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
1968
+ project = {
1969
+ uuid: (await api.post("/projects", {
1970
+ name: projectName,
1971
+ description: "DjangoStack"
1972
+ })).uuid,
1973
+ name: projectName
1974
+ };
1975
+ ask.step(`Created the Coolify project ${projectName}`);
1976
+ }
1977
+ const environmentName = options.environment ?? "production";
1978
+ let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
1979
+ if (!environment) {
1980
+ environment = {
1981
+ uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
1982
+ name: environmentName
1983
+ };
1984
+ ask.step(`Created the environment ${environmentName}`);
1985
+ }
1986
+ let githubApp;
1987
+ if (options.githubApp && options.githubApp !== "public") {
1988
+ const app = find$1(await api.get("/github-apps"), options.githubApp);
1989
+ if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
1990
+ githubApp = app.uuid;
1991
+ } else if (!options.githubApp && ask.interactive) {
1992
+ const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
1993
+ if (apps.length) {
1994
+ const choice = await ask.select("How Coolify gets the code", [{
1995
+ value: "public",
1996
+ label: "Public repository",
1997
+ hint: "no access needed"
1998
+ }, ...apps.map((app) => ({
1999
+ value: app.uuid,
2000
+ label: `GitHub App ${app.name}`,
2001
+ hint: "private repository"
2002
+ }))], "public");
2003
+ githubApp = choice === "public" ? void 0 : choice;
2004
+ }
2005
+ }
2006
+ const body = {
2007
+ project_uuid: project.uuid,
2008
+ server_uuid: server.uuid,
2009
+ environment_uuid: environment.uuid,
2010
+ git_repository: context.repository,
2011
+ git_branch: context.branch,
2012
+ build_pack: "dockercompose",
2013
+ docker_compose_location: "/compose.yaml",
2014
+ docker_compose_domains: [{
2015
+ name: "proxy",
2016
+ domain: `https://${context.domain}:80`
2017
+ }],
2018
+ name: context.name,
2019
+ description: "DjangoStack",
2020
+ instant_deploy: false,
2021
+ ...githubApp ? { github_app_uuid: githubApp } : {}
2022
+ };
2023
+ const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
2024
+ ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
2025
+ const link = {
2026
+ provider: "coolify",
2027
+ url: api.url,
2028
+ project: project.uuid,
2029
+ environment: environment.uuid,
2030
+ application: created.uuid
2031
+ };
2032
+ await this.pushEnv(link, context.env);
2033
+ ask.step(`Set ${Object.keys(context.env).length} environment variables`);
2034
+ return {
2035
+ project: link.project,
2036
+ environment: link.environment,
2037
+ application: link.application
2038
+ };
2039
+ }
2040
+ async pushEnv(link, env) {
2041
+ const data = Object.entries(env).map(([key, value]) => ({
2042
+ key,
2043
+ value,
2044
+ is_literal: true,
2045
+ is_preview: false
2046
+ }));
2047
+ await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
2048
+ }
2049
+ async deploy(link, options) {
2050
+ const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
2051
+ const result = await this.api.post(`/deploy?${query}`);
2052
+ const id = result?.deployments?.[0]?.deployment_uuid;
2053
+ if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
2054
+ return id;
684
2055
  }
685
2056
  async deployment(link, id) {
686
- if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
687
- const deploys = shellQuote(`${link.project}/deploys`);
688
- const output = await this.sh(`
689
- cd ${deploys} || exit 1
690
- [ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
691
- cat ${shellQuote(`${id}.json`)}
692
- pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
693
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
694
- printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
695
- echo ${MARK}
696
- cat ${shellQuote(`${id}.log`)} 2>/dev/null
697
- `);
698
- if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
699
- const [head = "", log = ""] = output.split(`\n${MARK}\n`);
700
- const [json = "{}", state = ""] = head.split("\n");
701
- const record = JSON.parse(json);
702
- const [status = "", alive = "0", updatedAt] = state.split(" ");
703
- return {
704
- ...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
705
- log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
706
- text,
707
- stderr: false
708
- })) : []
709
- };
2057
+ return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
710
2058
  }
711
2059
  async deployments(link, limit) {
712
- return (await this.records(link, limit)).map((item) => ({
713
- ...toDeployment(item.record, item.status, item.updatedAt),
714
- log: []
715
- }));
2060
+ const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
2061
+ return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
716
2062
  }
717
2063
  async status(link) {
718
- const [ps = "", site = ""] = parts(await this.sh(`
719
- docker compose -p ${shellQuote(link.application)} ps --all --format json
720
- echo ${MARK}
721
- sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
722
- `));
723
- const { state, detail } = composeState(parseComposePs(ps));
724
- const domain = site.trim().replace(/^["']|["']$/g, "");
2064
+ const app = await this.api.get(`/applications/${enc(link.application)}`);
725
2065
  return {
726
- state,
727
- detail,
728
- domains: domain ? [domain] : []
2066
+ state: appState(app.status),
2067
+ detail: app.status ?? "unknown",
2068
+ domains: composeDomains(app),
2069
+ branch: app.git_branch,
2070
+ url: appUrl(this.api, link)
729
2071
  };
730
2072
  }
731
2073
  };
732
- function toDeployment(record, status, updatedAt) {
733
- return {
734
- id: record.id,
735
- status,
736
- commit: record.commit,
737
- message: record.message,
738
- createdAt: record.createdAt,
739
- updatedAt
740
- };
741
- }
742
- /**
743
- * A value for a Compose .env line: bare when safe, else single-quoted (taken
744
- * literally), or double-quoted with escapes when it holds a single quote.
745
- */
746
- function envValue(value) {
747
- if (/^[\w@%+=:,./-]*$/.test(value)) return value;
748
- if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
749
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
750
- }
751
- function defaultGit(args) {
752
- return execFileSync("git", args, {
753
- maxBuffer: 1073741824,
754
- stdio: [
755
- "ignore",
756
- "pipe",
757
- "pipe"
758
- ]
759
- });
760
- }
761
- const ssh = {
762
- id: "ssh",
763
- name: "SSH server",
764
- auth: { kind: "ssh" },
765
- source: "local",
766
- target: {
767
- label: "Server",
768
- placeholder: "deploy@example.com",
769
- normalize: (value) => formatTarget(parseTarget(value))
2074
+ const coolify = {
2075
+ id: "coolify",
2076
+ name: "Coolify",
2077
+ auth: {
2078
+ kind: "token",
2079
+ env: "DSTACK_COOLIFY_TOKEN",
2080
+ help: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`
770
2081
  },
771
- platformEnv: {},
772
- setupFlags: [{
773
- name: "path",
774
- description: "The project directory on the server (default: /srv/<project>)"
2082
+ source: "remote",
2083
+ target: urlTarget("Coolify", "https://coolify.example.com"),
2084
+ platformEnv: PLATFORM_ENV,
2085
+ setupFlags: [...SHARED_SETUP_FLAGS, {
2086
+ name: "githubApp",
2087
+ description: "A Coolify GitHub App for a private repository, or public"
775
2088
  }],
776
- connect: (target) => new SshClient(target)
2089
+ connect: (url, token) => {
2090
+ if (!token) throw new Error("Coolify needs an API key.");
2091
+ return new CoolifyClient(url, token);
2092
+ }
777
2093
  };
778
2094
  // src/deploy/dokploy.ts
779
2095
  var DokployError = class extends Error {
@@ -1164,123 +2480,14 @@ function deleteToken(provider, url) {
1164
2480
  } catch {}
1165
2481
  return removeFromFile(account) || removed;
1166
2482
  }
1167
- // src/deploy/link.ts
1168
- /** Where a project deploys, relative to the project directory. It holds no secrets and can be committed. */
1169
- const LINK_FILE = ".dstack/deploy.json";
1170
- function readLink(directory) {
1171
- const path = join(directory, LINK_FILE);
1172
- if (!existsSync(path)) return void 0;
1173
- let link;
1174
- try {
1175
- link = JSON.parse(readFileSync(path, "utf8"));
1176
- } catch {
1177
- throw new Error(`${LINK_FILE} is not valid JSON.`);
1178
- }
1179
- for (const key of [
1180
- "provider",
1181
- "url",
1182
- "project",
1183
- "environment",
1184
- "application"
1185
- ]) if (typeof link[key] !== "string" || !link[key]) throw new Error(`${LINK_FILE} has no ${key}.`);
1186
- return link;
1187
- }
1188
- function writeLink(directory, link) {
1189
- const path = join(directory, LINK_FILE);
1190
- mkdirSync(dirname(path), { recursive: true });
1191
- const { provider, url, project, environment, application } = link;
1192
- writeFileSync(path, `${JSON.stringify({
1193
- provider,
1194
- url,
1195
- project,
1196
- environment,
1197
- application
1198
- }, null, 2)}\n`);
1199
- }
1200
- /** An https URL for a git remote: git@github.com:owner/repo.git becomes https://github.com/owner/repo. */
1201
- function httpsRepository(remote) {
1202
- const value = remote.trim();
1203
- const scp = value.match(/^[\w.-]+@([\w.-]+):(.+)$/);
1204
- let url;
1205
- if (scp) url = `https://${scp[1]}/${scp[2]}`;
1206
- else try {
1207
- const parsed = new URL(value);
1208
- url = `https://${parsed.host.replace(/:\d+$/, "")}${parsed.pathname}`;
1209
- } catch {
1210
- throw new Error(`Cannot read the git remote ${value}.`);
1211
- }
1212
- return url.replace(/\.git$/, "").replace(/\/$/, "");
1213
- }
1214
- function git(directory, args) {
1215
- try {
1216
- return execFileSync("git", args, {
1217
- cwd: directory,
1218
- encoding: "utf8",
1219
- stdio: [
1220
- "ignore",
1221
- "pipe",
1222
- "ignore"
1223
- ]
1224
- }).trim();
1225
- } catch {
1226
- return;
1227
- }
1228
- }
1229
- function gitRemote(directory, remote = "origin") {
1230
- return git(directory, [
1231
- "remote",
1232
- "get-url",
1233
- remote
1234
- ]) || void 0;
1235
- }
1236
- function gitBranch(directory) {
1237
- const branch = git(directory, [
1238
- "rev-parse",
1239
- "--abbrev-ref",
1240
- "HEAD"
1241
- ]);
1242
- return branch && branch !== "HEAD" ? branch : void 0;
1243
- }
1244
- /**
1245
- * Why a deploy of `branch` would not include the local commit, or undefined
1246
- * when origin has it. The platform builds from the remote, not from here.
1247
- */
1248
- function unpushedReason(directory, branch) {
1249
- const head = git(directory, ["rev-parse", "HEAD"]);
1250
- if (!head) return void 0;
1251
- if (git(directory, [
1252
- "rev-parse",
1253
- "--verify",
1254
- "--quiet",
1255
- `refs/remotes/origin/${branch}`
1256
- ]) === void 0) return `origin has no branch ${branch}. Push it first: git push -u origin ${branch}`;
1257
- if (git(directory, [
1258
- "merge-base",
1259
- "--is-ancestor",
1260
- head,
1261
- `refs/remotes/origin/${branch}`
1262
- ]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
1263
- }
1264
- /**
1265
- * Why an upload of HEAD would not match the working tree, or undefined when
1266
- * it does. Providers that upload the local commit deploy HEAD only, so
1267
- * changes to tracked files must be committed first.
1268
- */
1269
- function uncommittedReason(directory) {
1270
- if (git(directory, ["rev-parse", "HEAD"]) === void 0) return "This project has no commit yet. Commit it first: git add -A && git commit";
1271
- if (git(directory, [
1272
- "status",
1273
- "--porcelain",
1274
- "--untracked-files=no"
1275
- ])) return "You have uncommitted changes, so the deploy would not include them. Commit first.";
1276
- }
1277
2483
  // src/deploy/index.ts
1278
2484
  const DEFAULT_PROVIDER = coolify;
1279
2485
  /** Every deploy provider. */
1280
2486
  const PROVIDERS = [
1281
2487
  coolify,
1282
2488
  dokploy,
1283
- ssh
2489
+ ssh,
2490
+ bunny
1284
2491
  ];
1285
2492
  function getProvider(id) {
1286
2493
  const provider = PROVIDERS.find((item) => item.id === id);
@@ -1362,14 +2569,21 @@ function projectManifest(project) {
1362
2569
  /**
1363
2570
  * The project's stack: from the preset `init` saved in `package.json`, or,
1364
2571
  * for projects from before that, the defaults with the storage from `.env`.
2572
+ * Analytics follow the `analytics` profile: projects from before Umami was
2573
+ * generated have the default in their preset without running it.
1365
2574
  */
1366
2575
  function projectStack(project) {
2576
+ const analytics = project.profiles.includes("analytics") ? "umami" : "off";
1367
2577
  const { preset } = projectManifest(project);
1368
2578
  if (preset) try {
1369
- return decodePreset(preset);
2579
+ return {
2580
+ ...decodePreset(preset),
2581
+ analytics
2582
+ };
1370
2583
  } catch {}
1371
2584
  return {
1372
2585
  ...DEFAULT_STACK,
2586
+ analytics,
1373
2587
  storage: project.profiles.includes("s3") ? "s3" : "local"
1374
2588
  };
1375
2589
  }
@@ -1650,7 +2864,7 @@ async function deploySetup(options) {
1650
2864
  }))
1651
2865
  }));
1652
2866
  const provider = providerOrFail(providerId);
1653
- let instance = options.url;
2867
+ let instance = options.url ?? provider.target.fixed;
1654
2868
  if (!instance) {
1655
2869
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1656
2870
  instance = unwrap(await p.text({
@@ -1693,6 +2907,7 @@ async function deploySetup(options) {
1693
2907
  for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
1694
2908
  const ids = await attempt(client.setup({
1695
2909
  ask: promptAsk(interactive),
2910
+ directory: project.directory,
1696
2911
  name: project.name,
1697
2912
  repository,
1698
2913
  branch,
@@ -1703,11 +2918,12 @@ async function deploySetup(options) {
1703
2918
  const link = {
1704
2919
  provider: provider.id,
1705
2920
  url: instance,
1706
- ...ids
2921
+ ...ids,
2922
+ directory: project.directory
1707
2923
  };
1708
2924
  writeLink(project.directory, link);
1709
2925
  p.log.success(`Wrote ${pc.bold(LINK_FILE)}. It holds no secrets; commit it so others deploy to the same place.`);
1710
- p.note([`Point the DNS of ${pc.bold(domain)} at the ${provider.auth.kind === "ssh" ? "" : `${provider.name} `}server.`, provider.auth.kind === "ssh" ? `To change variables, edit ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.` : `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
2926
+ p.note([...provider.printsDns ? [] : [`Point the DNS of ${pc.bold(domain)} at the ${provider.auth.kind === "ssh" ? "" : `${provider.name} `}server.`], provider.auth.kind === "ssh" ? `To change variables, edit ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.` : `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
1711
2927
  if (options.deploy ?? (interactive ? unwrap(await p.confirm({
1712
2928
  message: "Deploy now?",
1713
2929
  initialValue: true
@@ -1901,7 +3117,7 @@ async function loginTarget(options, interactive) {
1901
3117
  } catch {}
1902
3118
  const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
1903
3119
  tokenAuth(provider);
1904
- let instance = options.url ?? link?.url;
3120
+ let instance = options.url ?? link?.url ?? provider.target.fixed;
1905
3121
  if (!instance) {
1906
3122
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1907
3123
  instance = unwrap(await p.text({
@@ -2350,6 +3566,7 @@ async function init(directoryArgument, options) {
2350
3566
  demoContent,
2351
3567
  databaseUrl,
2352
3568
  localS3: stack.storage === "s3",
3569
+ analytics: stack.analytics === "umami",
2353
3570
  preset: encodePreset(stack),
2354
3571
  timeZone,
2355
3572
  link: options.link
@@ -2452,7 +3669,7 @@ async function production(directoryArgument, options) {
2452
3669
  }
2453
3670
  let result;
2454
3671
  try {
2455
- result = productionEnv(readFileSync(envPath, "utf8"), {
3672
+ result = productionEnv$1(readFileSync(envPath, "utf8"), {
2456
3673
  domain,
2457
3674
  emailUrl: options.emailUrl
2458
3675
  });
@@ -2473,7 +3690,7 @@ async function production(directoryArgument, options) {
2473
3690
  }
2474
3691
  // src/config.ts
2475
3692
  /** The options `config` can switch in an existing project. The rest follow with code generation. */
2476
- const SWITCHABLE = ["storage"];
3693
+ const SWITCHABLE = ["storage", "analytics"];
2477
3694
  const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
2478
3695
  /**
2479
3696
  * Applies a new stack to a project's `.env`. Throws with a one-line reason
@@ -2482,7 +3699,7 @@ const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key
2482
3699
  function applyStack(envText, env, from, to) {
2483
3700
  let text = envText;
2484
3701
  const changes = [];
2485
- const profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
3702
+ let profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
2486
3703
  for (const option of STACK_OPTIONS) {
2487
3704
  const key = option.key;
2488
3705
  if (from[key] === to[key]) continue;
@@ -2493,14 +3710,33 @@ function applyStack(envText, env, from, to) {
2493
3710
  if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
2494
3711
  const endpoint = env.S3_ENDPOINT_URL ?? "";
2495
3712
  if (endpoint && !endpoint.includes("s3:8333")) throw new Error(`S3_ENDPOINT_URL points at ${endpoint}; change the S3_* values in .env yourself.`);
2496
- const next = profiles.filter((profile) => profile !== "s3");
2497
- if (to.storage === "s3") next.push("s3");
3713
+ profiles = profiles.filter((profile) => profile !== "s3");
3714
+ if (to.storage === "s3") profiles.push("s3");
2498
3715
  text = setEnvValues(text, {
2499
3716
  ...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
2500
- COMPOSE_PROFILES: next.join(",")
3717
+ COMPOSE_PROFILES: profiles.join(",")
2501
3718
  });
2502
3719
  changes.push(to.storage === "s3" ? "Uploads go to the local S3 service (the s3 profile)." : "Uploads go to a Docker volume; the local S3 service is off.");
2503
3720
  }
3721
+ if (key === "analytics") {
3722
+ profiles = profiles.filter((profile) => profile !== "analytics");
3723
+ if (to.analytics === "umami") {
3724
+ profiles.push("analytics");
3725
+ const fresh = umamiEnv(env.DATABASE_URL ?? "", generateProjectSecrets());
3726
+ const kept = Object.fromEntries(Object.entries(fresh).filter(([name]) => name === "UMAMI_URL" || !env[name]));
3727
+ text = setEnvValues(text, {
3728
+ ...kept,
3729
+ COMPOSE_PROFILES: profiles.join(",")
3730
+ });
3731
+ changes.push("Page analytics with Umami are on (the analytics profile).");
3732
+ } else {
3733
+ text = setEnvValues(text, {
3734
+ UMAMI_URL: "",
3735
+ COMPOSE_PROFILES: profiles.join(",")
3736
+ });
3737
+ changes.push("Page analytics are off; Umami's data stays in the database.");
3738
+ }
3739
+ }
2504
3740
  }
2505
3741
  return {
2506
3742
  env: text,
@@ -2539,7 +3775,7 @@ function exitWith(status) {
2539
3775
  function ensureRunEnv(project) {
2540
3776
  const path = join(project.directory, RUN_ENV);
2541
3777
  if (!existsSync(path)) {
2542
- const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
3778
+ const result = productionEnv$1(readFileSync(join(project.directory, ".env"), "utf8"), {
2543
3779
  domain: RUN_DOMAIN,
2544
3780
  emailUrl: "console://"
2545
3781
  });
@@ -2931,6 +4167,10 @@ async function config(options) {
2931
4167
  }))
2932
4168
  }));
2933
4169
  }
4170
+ if (next.analytics === "umami" && current.analytics !== "umami") {
4171
+ const compose = join(project.directory, "compose.yaml");
4172
+ if (!existsSync(compose) || !/^ {2}umami:/m.test(readFileSync(compose, "utf8"))) fail("compose.yaml has no umami service. Add it as the analytics docs describe, then run this again.");
4173
+ }
2934
4174
  const envPath = join(project.directory, ".env");
2935
4175
  let result;
2936
4176
  try {
@@ -3001,7 +4241,7 @@ function createProgram() {
3001
4241
  program.command("restore").description("Restore a backup over the database or the media (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("--media", "Also restore the media backed up with this database dump").option("-y, --yes", "Don't ask for confirmation").action(restore);
3002
4242
  program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
3003
4243
  const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
3004
- const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy or a server over SSH, and show the status");
4244
+ const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
3005
4245
  deployCommand.command("start", { isDefault: true }).description("Start a deploy and follow it; the same as `dstack deploy`").addOption(dirOption()).option("--force", "Rebuild the images without cache").option("--no-wait", "Don't follow the deploy; return once it started").option("--no-push-check", "Deploy even when it would not include your local commits or changes").option("-y, --yes", "Don't ask; fail when no API key is saved").action(deploy);
3006
4246
  const setupCommand = deployCommand.command("setup").description("Create the project on the platform and link it to this directory").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address, such as https://coolify.example.com, or the server, such as deploy@example.com").option("--repository <url>", "The git repository the platform builds (default: the origin remote)").option("--branch <branch>", "The branch to deploy (default: the current one)").option("--deploy", "Deploy when done").option("--no-deploy", "Don't offer to deploy when done").option("--force", `Set up again, replacing the link in .dstack/deploy.json`).option("-y, --yes", "Don't ask; use the flags and the defaults").action(deploySetup);
3007
4247
  const seen = /* @__PURE__ */ new Set();